agentsam-sdk 0.1.0__py3-none-any.whl

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.
Files changed (51) hide show
  1. agentsam_sdk/__init__.py +13 -0
  2. agentsam_sdk/cli.py +263 -0
  3. agentsam_sdk/client.py +220 -0
  4. agentsam_sdk/data/__init__.py +0 -0
  5. agentsam_sdk/data/agentsam_walk.py +158 -0
  6. agentsam_sdk/data/d1_adapter.py +124 -0
  7. agentsam_sdk/data/d1_bloat.py +445 -0
  8. agentsam_sdk/errors/__init__.py +4 -0
  9. agentsam_sdk/errors/vocabulary.py +2740 -0
  10. agentsam_sdk/hooks/__init__.py +32 -0
  11. agentsam_sdk/hooks/adapters.py +132 -0
  12. agentsam_sdk/hooks/contracts.py +121 -0
  13. agentsam_sdk/hooks/runtime.py +253 -0
  14. agentsam_sdk/knowledge/__init__.py +3 -0
  15. agentsam_sdk/knowledge/client.py +33 -0
  16. agentsam_sdk/knowledge/models.py +126 -0
  17. agentsam_sdk/py.typed +0 -0
  18. agentsam_sdk/repository/__init__.py +12 -0
  19. agentsam_sdk/repository/__main__.py +3 -0
  20. agentsam_sdk/repository/inspect.py +496 -0
  21. agentsam_sdk/repository/intelligence/__init__.py +5 -0
  22. agentsam_sdk/repository/intelligence/__main__.py +3 -0
  23. agentsam_sdk/repository/intelligence/cli.py +84 -0
  24. agentsam_sdk/repository/intelligence/discovery.py +153 -0
  25. agentsam_sdk/repository/intelligence/git.py +84 -0
  26. agentsam_sdk/repository/intelligence/metrics.py +92 -0
  27. agentsam_sdk/repository/intelligence/snapshot.py +108 -0
  28. agentsam_sdk/repository/inventory.py +352 -0
  29. agentsam_sdk/repository/recon/__init__.py +28 -0
  30. agentsam_sdk/repository/recon/__main__.py +3 -0
  31. agentsam_sdk/repository/recon/cli.py +178 -0
  32. agentsam_sdk/repository/recon/packet.py +294 -0
  33. agentsam_sdk/repository/recon/validate.py +76 -0
  34. agentsam_sdk/repository/scan_bloat.py +173 -0
  35. agentsam_sdk/runtime/__init__.py +0 -0
  36. agentsam_sdk/runtime/contract.py +119 -0
  37. agentsam_sdk/tui/__init__.py +52 -0
  38. agentsam_sdk/tui/__main__.py +4 -0
  39. agentsam_sdk/tui/bootstrap.py +15 -0
  40. agentsam_sdk/tui/demo.py +306 -0
  41. agentsam_sdk/tui/frames.py +147 -0
  42. agentsam_sdk/tui/onboarding.py +208 -0
  43. agentsam_sdk/tui/status.py +87 -0
  44. agentsam_sdk/tui/theme.py +57 -0
  45. agentsam_sdk/tui/widgets.py +218 -0
  46. agentsam_sdk-0.1.0.dist-info/METADATA +107 -0
  47. agentsam_sdk-0.1.0.dist-info/RECORD +51 -0
  48. agentsam_sdk-0.1.0.dist-info/WHEEL +5 -0
  49. agentsam_sdk-0.1.0.dist-info/entry_points.txt +2 -0
  50. agentsam_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
  51. agentsam_sdk-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,13 @@
1
+ """agentsam_sdk — stdlib-only toolkit for inneranimalmedia D1/repo audits.
2
+
3
+ Every tool follows the runtime.contract pattern: ToolInput in, ToolResult out,
4
+ plus a receipt written to output_dir for audit trail. No hardcoded
5
+ identity/repo/workspace/tenant values anywhere in this package — see
6
+ runtime.contract.HARD_LAW_NOTE.
7
+ """
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ # Public local SAM client. Remote ACP, MCP and app-specific authorities remain
12
+ # separately scoped; their mere existence does not grant this client access.
13
+ from .client import AgentSamClient, AsyncAgentSamClient, Choose, Check, Score, SamClientError
agentsam_sdk/cli.py ADDED
@@ -0,0 +1,263 @@
1
+ """agentsam CLI -- stdlib argparse only (no click/typer dep, per project
2
+ convention: Python tooling here is stdlib-only).
3
+
4
+ agentsam data d1-bloat --quick --format markdown --output-dir /tmp/d1-bloat
5
+ agentsam data agentsam-walk --prefix agentsam_ --output-dir /tmp/walk
6
+ agentsam repository inventory --repo-root .. --output-dir /tmp/scan --format json
7
+ agentsam repository inventory --repo-root .. --format json | jq '.data.totals'
8
+ agentsam repository scan-bloat --root src --min-kb 10 --top 30 --format json
9
+ agentsam repository inspect --repo-root . --json --dupes | jq '.duplicates'
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import sys
16
+
17
+ from agentsam_sdk.runtime.contract import ToolInput, ToolResult
18
+
19
+
20
+ def _print_result(result: ToolResult, fmt: str) -> None:
21
+ if fmt == "json":
22
+ print(json.dumps(result.to_dict(), indent=2, default=str))
23
+ else:
24
+ status = "OK" if result.ok else "FAIL"
25
+ print(f"[{status}] {result.tool} ({result.mode}) — {result.summary}", file=sys.stderr)
26
+ if result.error:
27
+ print(f" error: {result.error}", file=sys.stderr)
28
+ for a in result.artifacts:
29
+ print(f" wrote: {a}", file=sys.stderr)
30
+ # When markdown artifacts were written, surface path; else dump summary data keys
31
+ if fmt == "markdown" and result.ok and result.data.get("categories"):
32
+ # stdout stays quiet when artifacts exist; callers use the .md file
33
+ if not result.artifacts:
34
+ from agentsam_sdk.repository.inventory import _markdown
35
+
36
+ sys.stdout.write(_markdown(result.data))
37
+ if fmt == "markdown" and result.ok and result.data.get("files") is not None:
38
+ from agentsam_sdk.repository.scan_bloat import human_table
39
+
40
+ sys.stdout.write(
41
+ human_table(
42
+ result.data.get("files") or [],
43
+ scanned=int(result.data.get("file_count") or 0),
44
+ total_kb=float(result.data.get("total_kb") or 0),
45
+ total_tokens=int(result.data.get("total_est_tokens") or 0),
46
+ )
47
+ )
48
+
49
+
50
+ def _cmd_data_d1_bloat(args: argparse.Namespace) -> int:
51
+ from agentsam_sdk.data import d1_bloat
52
+
53
+ mode = "full" if args.full else "quick"
54
+ ti = ToolInput(
55
+ mode=mode,
56
+ params={
57
+ "db": args.db, "config": args.config, "repo_root": args.repo_root,
58
+ "prefix": args.prefix, "workers": args.workers, "top": args.top,
59
+ },
60
+ output_dir=args.output_dir,
61
+ )
62
+ result = d1_bloat.run(ti)
63
+ _print_result(result, args.format)
64
+ return 0 if result.ok else 1
65
+
66
+
67
+ def _cmd_data_agentsam_walk(args: argparse.Namespace) -> int:
68
+ from agentsam_sdk.data import agentsam_walk
69
+
70
+ ti = ToolInput(
71
+ mode="default",
72
+ params={"db": args.db, "config": args.config, "repo_root": args.repo_root, "prefix": args.prefix},
73
+ output_dir=args.output_dir,
74
+ )
75
+ result = agentsam_walk.run(ti)
76
+ _print_result(result, args.format)
77
+ return 0 if result.ok else 1
78
+
79
+
80
+ def _cmd_repository_inventory(args: argparse.Namespace) -> int:
81
+ from agentsam_sdk.repository import inventory
82
+
83
+ ti = ToolInput(
84
+ mode="read-only",
85
+ params={
86
+ "repo_root": args.repo_root,
87
+ "top": args.top,
88
+ "min_bytes": args.min_bytes,
89
+ "by_ext": args.by_ext,
90
+ "include_node_modules": args.include_node_modules,
91
+ "include_venvs": args.include_venvs,
92
+ "include_git": args.include_git,
93
+ "include_dist": args.include_dist,
94
+ "follow_symlinks": args.follow_symlinks,
95
+ },
96
+ output_dir=args.output_dir,
97
+ )
98
+ result = inventory.run(ti)
99
+ _print_result(result, args.format)
100
+ return 0 if result.ok else 1
101
+
102
+
103
+ def _cmd_repository_scan_bloat(args: argparse.Namespace) -> int:
104
+ from agentsam_sdk.repository import scan_bloat
105
+
106
+ ti = ToolInput(
107
+ mode="read-only",
108
+ params={
109
+ "root": args.root,
110
+ "top": args.top,
111
+ "min_kb": args.min_kb,
112
+ "ext": args.ext,
113
+ "exclude": args.exclude,
114
+ },
115
+ output_dir=args.output_dir,
116
+ )
117
+ result = scan_bloat.run(ti)
118
+ # Agent capture: --json-envelope prints data payload only (legacy tools/scan_bloat.py)
119
+ if args.json_envelope:
120
+ print(json.dumps(result.data if result.ok else {"ok": False, "error": result.error}, indent=2))
121
+ return 0 if result.ok else 1
122
+ _print_result(result, args.format)
123
+ return 0 if result.ok else 1
124
+
125
+
126
+ def _cmd_repository_inspect(args: argparse.Namespace) -> int:
127
+ from agentsam_sdk.repository import inspect as repo_inspect
128
+
129
+ argv: list[str] = []
130
+ if args.repo_root:
131
+ argv.extend(["--repo-root", args.repo_root])
132
+ want_text = bool(args.text) or args.format in ("text", "markdown")
133
+ want_json = bool(args.json) or args.format == "json" or not want_text
134
+ if want_json and not want_text:
135
+ argv.append("--json")
136
+ if want_text and not want_json:
137
+ argv.append("--text")
138
+ if want_text and want_json:
139
+ # Explicit both → JSON wins (machine default) unless only --text
140
+ argv.append("--json")
141
+ if args.dupes:
142
+ argv.append("--dupes")
143
+ if args.all:
144
+ argv.append("--all")
145
+ if args.since:
146
+ argv.extend(["--since", args.since])
147
+ if args.recent is not None:
148
+ argv.extend(["--recent", str(args.recent)])
149
+ if args.largest is not None:
150
+ argv.extend(["--largest", str(args.largest)])
151
+ if args.out:
152
+ argv.extend(["--out", args.out])
153
+ return repo_inspect.main_cli(argv)
154
+
155
+
156
+ def build_parser() -> argparse.ArgumentParser:
157
+ ap = argparse.ArgumentParser(prog="agentsam", description="agentsam_sdk CLI")
158
+ sub = ap.add_subparsers(dest="group", required=True)
159
+
160
+
161
+ data = sub.add_parser("data", help="D1 data audits")
162
+ data_sub = data.add_subparsers(dest="cmd", required=True)
163
+
164
+ bloat = data_sub.add_parser(
165
+ "d1-bloat",
166
+ help="database-scoped D1 audit (--quick=counts, --full=text sizes)",
167
+ )
168
+ bloat.add_argument("--db", help="D1 database name (else AGENTSAM_D1_DB_NAME)")
169
+ bloat.add_argument("--config", help="wrangler config path (else AGENTSAM_WRANGLER_CONFIG)")
170
+ bloat.add_argument("--repo-root", help="repo root wrangler runs from")
171
+ bloat.add_argument("--quick", action="store_true", default=True,
172
+ help="All tables: COUNT(*) only (default)")
173
+ bloat.add_argument("--full", action="store_true",
174
+ help="All tables: row counts + text/JSON LENGTH estimates")
175
+ bloat.add_argument("--prefix", help="Only tables whose name starts with this prefix")
176
+ bloat.add_argument("--workers", type=int, default=6)
177
+ bloat.add_argument("--top", type=int, default=40)
178
+ bloat.add_argument("--output-dir")
179
+ bloat.add_argument("--format", choices=["json", "markdown"], default="markdown")
180
+ bloat.set_defaults(func=_cmd_data_d1_bloat)
181
+
182
+ walk = data_sub.add_parser("agentsam-walk", help="walk agentsam_* tables")
183
+ walk.add_argument("--db")
184
+ walk.add_argument("--config")
185
+ walk.add_argument("--repo-root")
186
+ walk.add_argument("--prefix", default="agentsam_")
187
+ walk.add_argument("--output-dir")
188
+ walk.add_argument("--format", choices=["json", "markdown"], default="markdown")
189
+ walk.set_defaults(func=_cmd_data_agentsam_walk)
190
+
191
+ repo = sub.add_parser("repository", help="repository-level audits")
192
+ repo_sub = repo.add_subparsers(dest="cmd", required=True)
193
+ inv = repo_sub.add_parser(
194
+ "inventory",
195
+ help="file counts + sizes by logical category (jq-friendly JSON)",
196
+ )
197
+ inv.add_argument("--repo-root", default=".")
198
+ inv.add_argument("--output-dir")
199
+ inv.add_argument("--format", choices=["json", "markdown"], default="json")
200
+ inv.add_argument("--top", type=int, default=20, help="N largest files (0 to omit)")
201
+ inv.add_argument("--min-bytes", type=int, default=0)
202
+ inv.add_argument(
203
+ "--by-ext",
204
+ action=argparse.BooleanOptionalAction,
205
+ default=True,
206
+ help="Include by_extension_detail with byte rollups (default: on)",
207
+ )
208
+ inv.add_argument("--include-node-modules", action="store_true")
209
+ inv.add_argument("--include-venvs", action="store_true")
210
+ inv.add_argument("--include-git", action="store_true")
211
+ inv.add_argument("--include-dist", action="store_true")
212
+ inv.add_argument("--follow-symlinks", action="store_true")
213
+ inv.set_defaults(func=_cmd_repository_inventory)
214
+
215
+ sb = repo_sub.add_parser(
216
+ "scan-bloat",
217
+ help="largest runtime source files (KB/lines/est. tokens)",
218
+ )
219
+ sb.add_argument("--root", default=".", help="directory to scan (default: cwd)")
220
+ sb.add_argument("--top", type=int, default=30)
221
+ sb.add_argument("--min-kb", type=float, default=0)
222
+ sb.add_argument(
223
+ "--ext",
224
+ default=".js,.ts,.jsx,.tsx,.mjs,.cjs",
225
+ help="comma-separated extensions",
226
+ )
227
+ sb.add_argument("--exclude", default="", help="extra dir names to exclude")
228
+ sb.add_argument("--output-dir")
229
+ sb.add_argument("--format", choices=["json", "markdown"], default="markdown")
230
+ sb.add_argument(
231
+ "--json-envelope",
232
+ action="store_true",
233
+ help="print ToolResult.data JSON only (agent/terminal capture)",
234
+ )
235
+ sb.set_defaults(func=_cmd_repository_scan_bloat)
236
+
237
+ insp = repo_sub.add_parser(
238
+ "inspect",
239
+ help="file walk: sizes + dates (+ optional --dupes SHA-256 groups)",
240
+ )
241
+ insp.add_argument("--repo-root", default=None)
242
+ insp.add_argument("--format", choices=["json", "markdown", "text"], default="json")
243
+ insp.add_argument("--json", action="store_true")
244
+ insp.add_argument("--text", action="store_true")
245
+ insp.add_argument("--dupes", action="store_true")
246
+ insp.add_argument("--all", action="store_true")
247
+ insp.add_argument("--since", default=None)
248
+ insp.add_argument("--recent", type=int, default=50)
249
+ insp.add_argument("--largest", type=int, default=30)
250
+ insp.add_argument("--out", default=None)
251
+ insp.set_defaults(func=_cmd_repository_inspect)
252
+
253
+ return ap
254
+
255
+
256
+ def main(argv: list[str] | None = None) -> int:
257
+ parser = build_parser()
258
+ args = parser.parse_args(argv)
259
+ return args.func(args)
260
+
261
+
262
+ if __name__ == "__main__":
263
+ sys.exit(main())
agentsam_sdk/client.py ADDED
@@ -0,0 +1,220 @@
1
+ """A real, bounded SAM local client: delegates to existing Node machinery.
2
+
3
+ The current Python 0.1 package is a repository/data toolkit, not a remotely
4
+ hosted universal SDK. This bridge exposes read-only deterministic capabilities
5
+ and refuses any write, D1, CMS or arbitrary terminal operation. A native ACP
6
+ client and remote per-installation identity are separately tracked work.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from dataclasses import dataclass
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ import shutil
16
+ import subprocess
17
+ import time
18
+ from urllib import request as urllib_request, error as urllib_error
19
+ from typing import Any, Mapping
20
+
21
+ class SamClientError(RuntimeError):
22
+ def __init__(self, code: str):
23
+ self.code = code
24
+ super().__init__(code)
25
+
26
+ @dataclass(frozen=True)
27
+ class Choose:
28
+ options: tuple[str, ...]
29
+ instructions: str = ""
30
+ def __init__(self, options: list[str] | tuple[str,...], instructions: str = ""):
31
+ object.__setattr__(self, "options", tuple(options))
32
+ object.__setattr__(self, "instructions", instructions)
33
+ def question(self) -> dict:
34
+ return {"type":"choose","options":list(self.options),"instructions":self.instructions,"evaluator_hint":"deterministic"}
35
+
36
+ @dataclass(frozen=True)
37
+ class Check:
38
+ instructions: str = ""
39
+ def question(self) -> dict:
40
+ return {"type":"check","instructions":self.instructions,"evaluator_hint":"deterministic"}
41
+
42
+ @dataclass(frozen=True)
43
+ class Score:
44
+ levels: tuple[str, ...]
45
+ instructions: str = ""
46
+ def __init__(self, levels: list[str] | tuple[str,...], instructions: str = ""):
47
+ object.__setattr__(self, "levels", tuple(levels))
48
+ object.__setattr__(self, "instructions", instructions)
49
+ def question(self) -> dict:
50
+ return {"type":"score","levels":list(self.levels),"instructions":self.instructions,"evaluator_hint":"heuristic"}
51
+
52
+ _BRIDGE_REL = Path("src/sam/bridge/stdio.mjs")
53
+ _NPM_PACKAGE = Path("node_modules/@inneranimalmedia/agentsam-sdk")
54
+
55
+
56
+ def _discover_bridge(cwd: str | Path | None) -> Path | None:
57
+ """Find the Node bridge without configuration.
58
+
59
+ Order: this tree's own npm/monorepo layout, then the working directory and
60
+ its parents (a checkout, or an installed ``@inneranimalmedia/agentsam-sdk``).
61
+ """
62
+ roots = [Path(__file__).resolve().parents[2]]
63
+ if cwd is not None:
64
+ start = Path(cwd).expanduser().resolve()
65
+ roots.extend([start, *start.parents])
66
+ for root in roots:
67
+ for candidate in (root / _BRIDGE_REL, root / _NPM_PACKAGE / _BRIDGE_REL):
68
+ if candidate.is_file():
69
+ return candidate
70
+ return None
71
+
72
+
73
+ def _bridge_path(explicit: str | None, cwd: str | Path | None = None) -> Path:
74
+ candidate = explicit or os.environ.get("AGENTSAM_SDK_JS_BRIDGE")
75
+ path = Path(candidate).expanduser().resolve() if candidate else _discover_bridge(cwd)
76
+ if path is None or not path.is_file():
77
+ raise SamClientError("sam_bridge_not_installed")
78
+ return path
79
+
80
+ def _binary(explicit: str | None) -> str:
81
+ node = explicit or os.environ.get("AGENTSAM_NODE") or shutil.which("node")
82
+ if not node:
83
+ raise SamClientError("sam_node_not_available")
84
+ return node
85
+
86
+ def _normalize_questions(questions: Mapping[str, Any] | list) -> dict | list:
87
+ if isinstance(questions, Mapping):
88
+ return {key:(val.question() if isinstance(val,(Choose,Check,Score)) else val)
89
+ for key,val in questions.items()}
90
+ return questions
91
+
92
+ def _decode(raw: bytes) -> Any:
93
+ try:
94
+ reply = json.loads(raw)
95
+ except (ValueError, TypeError) as exc:
96
+ raise SamClientError("sam_bridge_invalid_response") from exc
97
+ if not isinstance(reply,dict) or not reply.get("ok"):
98
+ raise SamClientError(str((reply.get("error") or {}).get("code","sam_bridge_failed")))
99
+ return reply["result"]
100
+
101
+ class _ControlPlane:
102
+ """Existing ACP HTTP endpoints, not a second agent-run database."""
103
+ def __init__(self, url: str, token: str):
104
+ if not url.startswith("https://") and not url.startswith("http://127.0.0.1:"):
105
+ raise SamClientError("sam_acp_https_required")
106
+ self.url=url.rstrip("/")
107
+ self.token=token
108
+ def request(self, route: str, payload: dict | None = None) -> dict:
109
+ body=json.dumps(payload).encode() if payload is not None else None
110
+ headers={"Accept":"application/json"}
111
+ if body is not None: headers["Content-Type"]="application/json"
112
+ if self.token: headers["Authorization"]="Bearer "+self.token
113
+ req=urllib_request.Request(self.url+route,data=body,headers=headers,
114
+ method="POST" if body is not None else "GET")
115
+ try:
116
+ with urllib_request.urlopen(req,timeout=20) as response:
117
+ value=json.loads(response.read())
118
+ except urllib_error.HTTPError as exc:
119
+ raise SamClientError("sam_acp_http_"+str(exc.code)) from exc
120
+ except (urllib_error.URLError, TimeoutError) as exc:
121
+ raise SamClientError("sam_acp_unavailable") from exc
122
+ return value
123
+ def start(self, goal: str, **kwargs):
124
+ return self.request("/v1/runs", {"objective":goal,
125
+ "source_client":"agentsam-sdk-python", "surface":kwargs.get("surface","sdk"),
126
+ "metadata":{"requested_operations":kwargs.get("operations",[]),
127
+ "requested_deliverables":kwargs.get("deliverables"),
128
+ "context":kwargs.get("context",{})}})
129
+ def get(self, id: str):
130
+ from urllib.parse import quote
131
+ return self.request("/v1/runs/"+quote(id,safe=""))
132
+ def events(self, id: str, after: int = -1, limit: int = 200):
133
+ from urllib.parse import quote
134
+ return self.request("/v1/runs/"+quote(id,safe="")+"/events?after="+str(int(after))+"&limit="+str(int(limit)))
135
+
136
+ class RunHandle:
137
+ def __init__(self, cp: _ControlPlane, row: dict):
138
+ self.id=row.get("id") or row.get("run_id")
139
+ if not self.id:raise SamClientError("sam_acp_run_missing_id")
140
+ self.status=row.get("status","unknown")
141
+ self._control=cp
142
+ def get(self):return self._control.get(self.id)
143
+ def events(self, *, after: int = -1, limit: int = 200):
144
+ return self._control.events(self.id,after,limit)
145
+ def wait(self, *, attempts: int = 60, interval: float = 1.0):
146
+ for index in range(attempts):
147
+ state=self.get()
148
+ if state.get("status") in ("completed","failed","cancelled","timed_out"):
149
+ return {"id":self.id,"status":state["status"],"run":state,"deliverables":[],
150
+ "note":"ACP authority must supply real evidence and artifact references."}
151
+ if index+1<attempts:time.sleep(interval)
152
+ raise SamClientError("sam_run_wait_timeout")
153
+
154
+ class AsyncRunHandle:
155
+ def __init__(self, sync: RunHandle):
156
+ self.id=sync.id
157
+ self.status=sync.status
158
+ self._sync=sync
159
+ async def get(self):return await asyncio.to_thread(self._sync.get)
160
+ async def events(self, **kwargs):return await asyncio.to_thread(self._sync.events,**kwargs)
161
+ async def wait(self, **kwargs):return await asyncio.to_thread(self._sync.wait,**kwargs)
162
+
163
+ class AgentSamClient:
164
+ """Synchronous local machine client; no implicit remote credentials."""
165
+ def __init__(self, *, cwd: str | Path = ".", node: str | None = None, bridge: str | None = None, control_plane_url: str | None = None, control_plane_token: str | None = None):
166
+ self.cwd = str(Path(cwd).resolve())
167
+ self.node = _binary(node)
168
+ self.bridge = str(_bridge_path(bridge, self.cwd))
169
+ self.control_plane = _ControlPlane(control_plane_url,control_plane_token or "") if control_plane_url else None
170
+ def _request(self, method: str, **kwargs) -> Any:
171
+ result = subprocess.run([self.node,self.bridge], input=json.dumps({"method":method,**kwargs}),
172
+ text=True, capture_output=True, cwd=self.cwd, timeout=120, check=False)
173
+ return _decode(result.stdout.encode())
174
+ def discover(self, **query): return self._request("discover",query=query)
175
+ def describe(self, operation_id: str): return self._request("describe",id=operation_id)
176
+ def status(self, operation_id: str): return self._request("status",id=operation_id)
177
+ def invoke(self, operation_id: str, input: dict | None = None):
178
+ return self._request("invoke",id=operation_id,input=input or {})
179
+ def decide(self, *, state: dict, questions: Mapping[str, Any] | list, **kwargs):
180
+ return self._request("decide",input={"state":state,"questions":_normalize_questions(questions),**kwargs})
181
+ def run(self, **kwargs):
182
+ if not self.control_plane: raise SamClientError("sam_python_acp_run_adapter_not_configured")
183
+ goal=kwargs.get("goal","")
184
+ if not isinstance(goal,str) or not goal.strip(): raise SamClientError("sam_run_goal_required")
185
+ result=self.control_plane.start(goal, **{k:v for k,v in kwargs.items() if k!="goal"})
186
+ row=result.get("run",result)
187
+ if not isinstance(row,dict) or not (row.get("id") or row.get("run_id")):raise SamClientError("sam_acp_run_missing_id")
188
+ return RunHandle(self.control_plane,row)
189
+ def events(self, run_id: str, **kwargs):
190
+ if not self.control_plane:raise SamClientError("sam_python_acp_run_adapter_not_configured")
191
+ return self.control_plane.events(run_id,**kwargs)
192
+ def __enter__(self):return self
193
+ def __exit__(self, *_):return False
194
+
195
+ class AsyncAgentSamClient(AgentSamClient):
196
+ """Async subprocess transport to the same authorized local SAM entrypoint."""
197
+ async def _request_async(self, method: str, **kwargs):
198
+ proc = await asyncio.create_subprocess_exec(self.node,self.bridge,stdin=asyncio.subprocess.PIPE,
199
+ stdout=asyncio.subprocess.PIPE,stderr=asyncio.subprocess.PIPE,cwd=self.cwd)
200
+ try:
201
+ out,_ = await asyncio.wait_for(proc.communicate(json.dumps({"method":method,**kwargs}).encode()),120)
202
+ except asyncio.TimeoutError as exc:
203
+ proc.kill()
204
+ await proc.wait()
205
+ raise SamClientError("sam_bridge_timeout") from exc
206
+ return _decode(out)
207
+ async def discover(self, **query):return await self._request_async("discover",query=query)
208
+ async def describe(self, operation_id: str):return await self._request_async("describe",id=operation_id)
209
+ async def status(self, operation_id: str):return await self._request_async("status",id=operation_id)
210
+ async def invoke(self, operation_id: str, input: dict | None = None):
211
+ return await self._request_async("invoke",id=operation_id,input=input or {})
212
+ async def decide(self, *, state: dict, questions: Mapping[str, Any] | list, **kwargs):
213
+ return await self._request_async("decide",input={"state":state,"questions":_normalize_questions(questions),**kwargs})
214
+ async def run(self, **kwargs):
215
+ handle=await asyncio.to_thread(super().run,**kwargs)
216
+ return AsyncRunHandle(handle)
217
+ async def events(self, run_id: str, **kwargs):
218
+ return await asyncio.to_thread(super().events,run_id,**kwargs)
219
+ async def __aenter__(self):return self
220
+ async def __aexit__(self,*_):return False
File without changes
@@ -0,0 +1,158 @@
1
+ """agentsam_sdk.data.agentsam_walk -- condensed port of
2
+ scripts/walk_agentsam_tables.py (+ schema-focus of scripts/d1_schema_audit.py).
3
+
4
+ Walks every `agentsam_%` sqlite_master object: schema, indexes, foreign
5
+ keys, row count, freshness (via *_at / *_at_epoch columns if present), and
6
+ groups tables into capability buckets by name-substring heuristics.
7
+
8
+ Note: this is a condensed re-implementation, not a byte-for-byte port of the
9
+ 801-line original -- see docs/gaps.md for what's folded in vs deferred
10
+ (duplicate-detection heuristics, per-feature markdown chunking into db/*.md
11
+ files a la d1_schema_audit.py are deferred to a follow-up pass).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field, asdict
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+ from typing import Optional
19
+
20
+ from agentsam_sdk.data.d1_adapter import D1Adapter, D1AdapterError
21
+ from agentsam_sdk.runtime.contract import ToolInput, ToolResult, write_receipt, start_timer
22
+
23
+ TOOL_NAME = "data.agentsam_walk"
24
+
25
+ CAPABILITY_RULES: dict[str, list[str]] = {
26
+ "agent_run_spine": ["run", "runs", "execution", "executions", "session", "sessions", "step", "steps", "message", "messages"],
27
+ "model_routing": ["model", "catalog", "routing", "route", "routes", "arm", "arms", "requirement", "provider", "prompt"],
28
+ "tools_commands_mcp": ["tool", "tools", "mcp", "command", "commands", "skill", "skills", "invocation", "chain", "script", "scripts"],
29
+ "workflow_dag": ["workflow", "workflows", "node", "nodes", "edge", "edges", "approval", "approvals", "task", "tasks"],
30
+ "memory_rag": ["memory", "embedding", "vector", "chunk", "chunks", "document", "documents", "rag"],
31
+ "observability": ["log", "logs", "trace", "traces", "metric", "metrics", "health", "analytics", "otlp", "error", "errors"],
32
+ "cms_content": ["cms_", "page", "pages", "content", "nav", "template"],
33
+ "security_policy": ["policy", "policies", "guardrail", "trusted", "origin", "auth", "credential"],
34
+ }
35
+
36
+
37
+ @dataclass
38
+ class TableWalk:
39
+ name: str
40
+ columns: list[dict] = field(default_factory=list)
41
+ indexes: list[dict] = field(default_factory=list)
42
+ foreign_keys: list[dict] = field(default_factory=list)
43
+ row_count: int = 0
44
+ has_freshness_col: bool = False
45
+ capability: str = "uncategorized"
46
+ error: Optional[str] = None
47
+ flags: list[str] = field(default_factory=list)
48
+
49
+
50
+ def _capability_for(table: str) -> str:
51
+ """Score by total matched-keyword *length*, not match count -- a single
52
+ specific hit ("workflow") should outrank two generic ones ("run", "runs").
53
+ """
54
+ lname = table.lower()
55
+ best, best_score = "uncategorized", 0
56
+ for cap, keywords in CAPABILITY_RULES.items():
57
+ score = sum(len(kw) for kw in keywords if kw in lname)
58
+ if score > best_score:
59
+ best, best_score = cap, score
60
+ return best
61
+
62
+
63
+ def _walk_table(adapter: D1Adapter, table: str) -> TableWalk:
64
+ tw = TableWalk(name=table, capability=_capability_for(table))
65
+ try:
66
+ cols = adapter.table_columns(table)
67
+ tw.columns = [{"name": n, "type": t} for n, t in cols]
68
+ tw.indexes = adapter.table_indexes(table)
69
+ tw.foreign_keys = adapter.foreign_keys(table)
70
+ tw.row_count = adapter.row_count(table)
71
+ tw.has_freshness_col = any(
72
+ n.lower() in ("updated_at", "created_at", "updated_at_epoch", "created_at_epoch")
73
+ for n, _ in cols
74
+ )
75
+ if tw.row_count == 0:
76
+ tw.flags.append("empty")
77
+ if not tw.has_freshness_col:
78
+ tw.flags.append("no_freshness_column")
79
+ if not tw.indexes and tw.row_count > 1000:
80
+ tw.flags.append("no_index_high_row_count")
81
+ except D1AdapterError as e:
82
+ tw.error = str(e)[:200]
83
+ except Exception as e: # noqa: BLE001
84
+ tw.error = str(e)[:200]
85
+ return tw
86
+
87
+
88
+ def _render_markdown(walks: list[TableWalk], prefix: str) -> str:
89
+ lines = [
90
+ f"# agentsam walk -- `{prefix}%` objects",
91
+ "",
92
+ f"- **Generated:** {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}",
93
+ f"- **Tables walked:** {len(walks)}",
94
+ "",
95
+ "## By capability",
96
+ "",
97
+ ]
98
+ by_cap: dict[str, list[TableWalk]] = {}
99
+ for w in walks:
100
+ by_cap.setdefault(w.capability, []).append(w)
101
+ for cap in sorted(by_cap):
102
+ lines.append(f"### {cap} ({len(by_cap[cap])})")
103
+ lines.append("")
104
+ lines.append("| Table | Rows | Indexes | FKs | Flags |")
105
+ lines.append("|-------|------|---------|-----|-------|")
106
+ for w in sorted(by_cap[cap], key=lambda x: x.row_count, reverse=True):
107
+ flags = ", ".join(w.flags) or "-"
108
+ err = f" ⚠ {w.error}" if w.error else ""
109
+ lines.append(f"| `{w.name}` | {w.row_count:,} | {len(w.indexes)} | {len(w.foreign_keys)} | {flags}{err} |")
110
+ lines.append("")
111
+ return "\n".join(lines)
112
+
113
+
114
+ def run(tool_input: ToolInput) -> ToolResult:
115
+ started = start_timer()
116
+ tool_input.assert_read_only()
117
+ p = tool_input.params
118
+ prefix = p.get("prefix", "agentsam_")
119
+ output_dir = tool_input.output_path()
120
+
121
+ try:
122
+ adapter = D1Adapter.from_env(
123
+ db_name=p.get("db"), wrangler_config=p.get("config"), repo_root=p.get("repo_root")
124
+ )
125
+ tables = adapter.list_tables(like=f"{prefix}%")
126
+ walks = [_walk_table(adapter, t) for t in tables]
127
+ md = _render_markdown(walks, prefix)
128
+ json_payload = {
129
+ "prefix": prefix,
130
+ "database": adapter.db_name,
131
+ "table_count": len(walks),
132
+ "tables": [asdict(w) for w in walks],
133
+ }
134
+
135
+ artifacts: list[str] = []
136
+ if output_dir:
137
+ output_dir.mkdir(parents=True, exist_ok=True)
138
+ (output_dir / "agentsam-walk.md").write_text(md, encoding="utf-8")
139
+ (output_dir / "agentsam-walk.json").write_text(
140
+ __import__("json").dumps(json_payload, indent=2), encoding="utf-8"
141
+ )
142
+ artifacts = [str(output_dir / "agentsam-walk.md"), str(output_dir / "agentsam-walk.json")]
143
+
144
+ result = ToolResult(
145
+ ok=True, tool=TOOL_NAME, mode=tool_input.mode, request_id=tool_input.request_id,
146
+ started_at=started, finished_at=start_timer(),
147
+ summary=f"Walked {len(walks)} `{prefix}%` tables.",
148
+ data=json_payload, artifacts=artifacts,
149
+ )
150
+ except D1AdapterError as e:
151
+ result = ToolResult(
152
+ ok=False, tool=TOOL_NAME, mode=tool_input.mode, request_id=tool_input.request_id,
153
+ started_at=started, finished_at=start_timer(),
154
+ summary="D1 adapter error", error=str(e),
155
+ )
156
+
157
+ write_receipt(result, output_dir)
158
+ return result