@inneranimalmedia/agentsam-sdk 1.7.0 → 1.8.0

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 (33) hide show
  1. package/DEVELOPMENT.md +24 -4
  2. package/README.md +2 -0
  3. package/docs/RELEASES.md +6 -0
  4. package/package.json +8 -3
  5. package/protocol/README.md +51 -0
  6. package/protocol/dual-repo-sync.md +35 -0
  7. package/python/README.md +12 -0
  8. package/python/agentsam_sdk/__init__.py +9 -0
  9. package/python/agentsam_sdk/cli.py +212 -0
  10. package/python/agentsam_sdk/data/__init__.py +0 -0
  11. package/python/agentsam_sdk/data/agentsam_walk.py +157 -0
  12. package/python/agentsam_sdk/data/d1_adapter.py +124 -0
  13. package/python/agentsam_sdk/data/d1_bloat.py +265 -0
  14. package/python/agentsam_sdk/repository/__init__.py +0 -0
  15. package/python/agentsam_sdk/repository/__main__.py +3 -0
  16. package/python/agentsam_sdk/repository/inventory.py +351 -0
  17. package/python/agentsam_sdk/repository/scan_bloat.py +173 -0
  18. package/python/agentsam_sdk/runtime/__init__.py +0 -0
  19. package/python/agentsam_sdk/runtime/contract.py +105 -0
  20. package/python/docs/gaps.md +63 -0
  21. package/python/docs/tooling.md +67 -0
  22. package/python/protocol/README.md +51 -0
  23. package/python/protocol/dual-repo-sync.md +35 -0
  24. package/python/pyproject.toml +16 -0
  25. package/python/scripts/check-host-tooling.sh +65 -0
  26. package/python/tests/__init__.py +0 -0
  27. package/python/tests/fixtures/sample_tables.json +17 -0
  28. package/python/tests/fixtures.py +95 -0
  29. package/python/tests/test_agentsam_walk.py +31 -0
  30. package/python/tests/test_contract.py +32 -0
  31. package/python/tests/test_d1_bloat.py +57 -0
  32. package/python/tests/test_repository_inventory.py +53 -0
  33. package/python/tests/test_scan_bloat.py +31 -0
package/DEVELOPMENT.md CHANGED
@@ -43,10 +43,30 @@ npx agentsam init \
43
43
 
44
44
  ## Publish checklist
45
45
 
46
- 1. `npm test` passes
47
- 2. Bump version in `package.json`
48
- 3. `npm publish --access public`
49
- 4. Tag: `git tag v1.1.1 && git push origin v1.1.1`
46
+ 1. Root `package.json` name is **`@inneranimalmedia/agentsam-sdk`** (never a workspace kit)
47
+ 2. `npm test` passes
48
+ 3. Bump version in root `package.json`
49
+ 4. Confirm `files` includes consumer surfaces (`src`, `bin`, `python`, `protocol`, …)
50
+ 5. `npm publish --access public` (manual; requires `npm login` to org)
51
+ 6. Tag: `git tag vX.Y.Z && git push origin vX.Y.Z`
52
+ 7. Record pairing in [`docs/RELEASES.md`](./docs/RELEASES.md): `iam@<40-sha> ↔ @inneranimalmedia/agentsam-sdk@X.Y.Z`
53
+
54
+ Workspace packages under `packages/*` (e.g. `@inneranimalmedia/agentsam-shell-kit`) stay
55
+ `private: true` until separately ready — they are **not** the root publish identity.
56
+
57
+ ## Python tooling (`agentsam_sdk`)
58
+
59
+ Portable stdlib audits live under [`python/`](./python/) (import path `agentsam_sdk.*`).
60
+
61
+ ```bash
62
+ cd python
63
+ python3 -m pip install -e ".[dev]"
64
+ python3 -m pytest
65
+ python3 -m agentsam_sdk.repository.inventory --json --root /path/to/repo
66
+ ```
67
+
68
+ Inner Animal Media keeps a thin shim at `scripts/repo-size-inventory.py` that adds
69
+ `AGENTSAM_SDK_ROOT/python` (or a sibling/`~/agentsam-sdk` checkout) to `sys.path`.
50
70
 
51
71
  ## Known gaps (roadmap)
52
72
 
package/README.md CHANGED
@@ -6,6 +6,8 @@ Agent Sam is a full-stack autonomous agent SDK built on Cloudflare Workers, D1,
6
6
 
7
7
  **Repo:** [github.com/SamPrimeaux/agentsam-sdk](https://github.com/SamPrimeaux/agentsam-sdk) · **npm:** `@inneranimalmedia/agentsam-sdk`
8
8
 
9
+ **Protocol (dual-home, no drift):** [`protocol/README.md`](./protocol/README.md) — every tool/feature lands in this repo **and** `inneranimalmedia/agentsam-sdk/`; npm publish is manual after mirror. Python lane: [`python/`](./python/).
10
+
9
11
  ---
10
12
 
11
13
  ## What is Agent Sam?
@@ -0,0 +1,6 @@
1
+ # `@inneranimalmedia/agentsam-sdk` release receipts
2
+
3
+ | npm version | Published (UTC) | IAM git SHA (40) | Notes |
4
+ |-------------|-----------------|------------------|-------|
5
+ | 1.7.0 | 2026-07-14 | _(pre-receipt)_ | Prior publish |
6
+ | 1.8.0 | _(pending publish)_ | `580547301e370b77ec26bd72558979d335feedd9` | `python/` + `protocol/` in npm `files`; `repository.scan_bloat`; shell-kit folded to `packages/agentsam-shell-kit` (private) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inneranimalmedia/agentsam-sdk",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Agent Sam is a full-stack AI agent SDK for autonomous task execution — covering data management, creative workflows, design commands, and multi-step agentic pipelines.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -18,10 +18,15 @@
18
18
  "docs",
19
19
  "examples",
20
20
  "test",
21
+ "python",
22
+ "protocol",
23
+ "bin",
21
24
  "README.md",
22
25
  "LICENSE",
23
- "DEVELOPMENT.md",
24
- "bin"
26
+ "DEVELOPMENT.md"
27
+ ],
28
+ "workspaces": [
29
+ "packages/*"
25
30
  ],
26
31
  "scripts": {
27
32
  "test": "node test/smoke.mjs",
@@ -0,0 +1,51 @@
1
+ # agentsam-sdk protocol (LOCKED)
2
+
3
+ Every Agent Sam **tool / feature SDK surface** is dual-homed. There is no
4
+ “Python audits only in IAM” vs “JS CLI only on npm” split for productized
5
+ tooling.
6
+
7
+ | Home | Path | Role |
8
+ |------|------|------|
9
+ | **Main platform repo** | `inneranimalmedia/agentsam-sdk/` | Source of truth while building; ships with platform deploy |
10
+ | **Published SDK repo** | `github.com/SamPrimeaux/agentsam-sdk` → npm `@inneranimalmedia/agentsam-sdk` | Same contract + modules for external / CLI consumers |
11
+
12
+ ## Rules
13
+
14
+ 1. **Author once, land twice.** New `agentsam_sdk.*` tools (data, repository,
15
+ readiness, history, code-intel, …) land in the monorepo package **and** are
16
+ mirrored into the published `agentsam-sdk` repo in the same change set / PR
17
+ pair. No silent one-sided land.
18
+ 2. **Contract is shared.** `runtime/contract` (`ToolInput` / `ToolResult` /
19
+ receipts, unixepoch, no hardcoded identity) is the protocol. Language may
20
+ differ (Python stdlib vs JS), but CLI verbs and JSON shapes must stay
21
+ aligned — see `protocol/dual-repo-sync.md`.
22
+ 3. **No drift.** Publishing npm without updating the monorepo copy (or the
23
+ reverse) is a protocol violation. Gate: version bump + changelog note that
24
+ lists mirrored paths.
25
+ 4. **Ship is two-sided.**
26
+ - Platform: Mac `npm run deploy:full` / `deploy:fast` (or GCP `ship:remote`)
27
+ when the monorepo copy or Worker wiring changed.
28
+ - SDK: **manual** npm publish / version bump on `agentsam-sdk` after the
29
+ mirror lands (operator-owned — do not assume CI auto-publishes).
30
+ 5. **Inspiration ≠ copy-paste secrets.** In-app surfaces (e.g. `src/core/code-indexer.js`,
31
+ AST-RAG Phase 1/2) are the reference implementations to port into portable
32
+ SDK modules — strip platform-only bindings, keep adapters for D1 / git /
33
+ Hyperdrive.
34
+
35
+ ## Layout (both homes)
36
+
37
+ ```
38
+ agentsam-sdk/
39
+ protocol/ ← this law
40
+ python/agentsam_sdk ← Python portable tools (stdlib-first audits / inventory)
41
+ src/ ← JS CLI / scaffold (published npm entry)
42
+ packages/ ← optional workspace packages (not always in npm tarball)
43
+ agentsam-shell-kit/ ← @inneranimalmedia/agentsam-shell-kit (private until ready)
44
+ docs/gaps.md ← port status vs IAM scripts + in-app indexers
45
+ ```
46
+
47
+ **npm identity (LOCKED):** root publishable package is always
48
+ `@inneranimalmedia/agentsam-sdk`. Do not overwrite root `package.json` with a
49
+ workspace kit name. Fold UI kits under `packages/*`.
50
+
51
+ Exact folder names may evolve; the dual-home + root-identity rules do not.
@@ -0,0 +1,35 @@
1
+ # Dual-repo sync checklist
2
+
3
+ Use this every time you add or change an `agentsam_sdk` tool/feature.
4
+
5
+ ## Before coding
6
+
7
+ - [ ] Name the module (`agentsam_sdk.<domain>.<tool>`) and CLI verb
8
+ - [ ] Confirm it belongs in SDK (portable) vs platform-only Worker hot path
9
+ - [ ] Note in-app inspiration path (if any), e.g. `src/core/code-indexer.js`
10
+
11
+ ## Land
12
+
13
+ - [ ] Implement + tests in **`inneranimalmedia/agentsam-sdk/`**
14
+ - [ ] Mirror the same module/CLI/docs into **`agentsam-sdk`** (npm repo)
15
+ - [ ] Update `docs/gaps.md` (or equivalent) in **both** trees
16
+ - [ ] Update `protocol/` only in monorepo if law changed; copy README blurb to npm
17
+
18
+ ## Publish / deploy (operator)
19
+
20
+ - [ ] Platform: commit/push IAM → deploy by host (`deploy:full` / `ship:remote`)
21
+ - [ ] SDK: bump `package.json` version in npm repo → `npm publish` (manual)
22
+ - [ ] Record versions next to each other (PR description or receipt):
23
+ `iam@<sha>` ↔ `@inneranimalmedia/agentsam-sdk@<semver>`
24
+
25
+ ## Drift signals (fail the PR)
26
+
27
+ - Module exists only in one repo
28
+ - CLI flag / JSON field renamed on one side only
29
+ - README claims “this is not the npm package” / “audits don’t belong here”
30
+ - npm publish without a same-day IAM mirror commit (or documented lag ticket)
31
+
32
+ ## jq / host tools
33
+
34
+ Host tooling (`jq`, `wrangler`, Python ≥3.10) is documented in
35
+ `docs/tooling.md`. Keep that file mirrored when recipes change.
@@ -0,0 +1,12 @@
1
+ # agentsam_sdk (Python) — npm mirror
2
+
3
+ Exact dual-home of `inneranimalmedia/agentsam-sdk/`.
4
+
5
+ ```bash
6
+ cd python && pip install -e .
7
+ agentsam repository inventory --repo-root /path/to/repo --format json
8
+ python3 -m unittest discover -s tests -v
9
+ ./scripts/check-host-tooling.sh
10
+ ```
11
+
12
+ Protocol: [`../protocol/README.md`](../protocol/README.md). Do not advance this tree without mirroring the monorepo (or the reverse).
@@ -0,0 +1,9 @@
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"
@@ -0,0 +1,212 @@
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
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import sys
15
+
16
+ from agentsam_sdk.runtime.contract import ToolInput, ToolResult
17
+
18
+
19
+ def _print_result(result: ToolResult, fmt: str) -> None:
20
+ if fmt == "json":
21
+ print(json.dumps(result.to_dict(), indent=2, default=str))
22
+ else:
23
+ status = "OK" if result.ok else "FAIL"
24
+ print(f"[{status}] {result.tool} ({result.mode}) — {result.summary}", file=sys.stderr)
25
+ if result.error:
26
+ print(f" error: {result.error}", file=sys.stderr)
27
+ for a in result.artifacts:
28
+ print(f" wrote: {a}", file=sys.stderr)
29
+ # When markdown artifacts were written, surface path; else dump summary data keys
30
+ if fmt == "markdown" and result.ok and result.data.get("categories"):
31
+ # stdout stays quiet when artifacts exist; callers use the .md file
32
+ if not result.artifacts:
33
+ from agentsam_sdk.repository.inventory import _markdown
34
+
35
+ sys.stdout.write(_markdown(result.data))
36
+ if fmt == "markdown" and result.ok and result.data.get("files") is not None:
37
+ from agentsam_sdk.repository.scan_bloat import human_table
38
+
39
+ sys.stdout.write(
40
+ human_table(
41
+ result.data.get("files") or [],
42
+ scanned=int(result.data.get("file_count") or 0),
43
+ total_kb=float(result.data.get("total_kb") or 0),
44
+ total_tokens=int(result.data.get("total_est_tokens") or 0),
45
+ )
46
+ )
47
+
48
+
49
+ def _cmd_data_d1_bloat(args: argparse.Namespace) -> int:
50
+ from agentsam_sdk.data import d1_bloat
51
+
52
+ mode = "full" if args.full else "quick"
53
+ ti = ToolInput(
54
+ mode=mode,
55
+ params={
56
+ "db": args.db, "config": args.config, "repo_root": args.repo_root,
57
+ "prefix": args.prefix, "workers": args.workers,
58
+ "count_only": args.count_only, "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 build_parser() -> argparse.ArgumentParser:
127
+ ap = argparse.ArgumentParser(prog="agentsam", description="agentsam_sdk CLI")
128
+ sub = ap.add_subparsers(dest="group", required=True)
129
+
130
+ data = sub.add_parser("data", help="D1 data audits")
131
+ data_sub = data.add_subparsers(dest="cmd", required=True)
132
+
133
+ bloat = data_sub.add_parser("d1-bloat", help="find largest/text-heavy D1 tables")
134
+ bloat.add_argument("--db", help="D1 database name (else AGENTSAM_D1_DB_NAME)")
135
+ bloat.add_argument("--config", help="wrangler config path (else AGENTSAM_WRANGLER_CONFIG)")
136
+ bloat.add_argument("--repo-root", help="repo root wrangler runs from")
137
+ bloat.add_argument("--quick", action="store_true", default=True)
138
+ bloat.add_argument("--full", action="store_true")
139
+ bloat.add_argument("--prefix")
140
+ bloat.add_argument("--workers", type=int, default=6)
141
+ bloat.add_argument("--top", type=int, default=40)
142
+ bloat.add_argument("--count-only", action="store_true")
143
+ bloat.add_argument("--output-dir")
144
+ bloat.add_argument("--format", choices=["json", "markdown"], default="markdown")
145
+ bloat.set_defaults(func=_cmd_data_d1_bloat)
146
+
147
+ walk = data_sub.add_parser("agentsam-walk", help="walk agentsam_* tables")
148
+ walk.add_argument("--db")
149
+ walk.add_argument("--config")
150
+ walk.add_argument("--repo-root")
151
+ walk.add_argument("--prefix", default="agentsam_")
152
+ walk.add_argument("--output-dir")
153
+ walk.add_argument("--format", choices=["json", "markdown"], default="markdown")
154
+ walk.set_defaults(func=_cmd_data_agentsam_walk)
155
+
156
+ repo = sub.add_parser("repository", help="repository-level audits")
157
+ repo_sub = repo.add_subparsers(dest="cmd", required=True)
158
+ inv = repo_sub.add_parser(
159
+ "inventory",
160
+ help="file counts + sizes by logical category (jq-friendly JSON)",
161
+ )
162
+ inv.add_argument("--repo-root", default=".")
163
+ inv.add_argument("--output-dir")
164
+ inv.add_argument("--format", choices=["json", "markdown"], default="json")
165
+ inv.add_argument("--top", type=int, default=20, help="N largest files (0 to omit)")
166
+ inv.add_argument("--min-bytes", type=int, default=0)
167
+ inv.add_argument(
168
+ "--by-ext",
169
+ action=argparse.BooleanOptionalAction,
170
+ default=True,
171
+ help="Include by_extension_detail with byte rollups (default: on)",
172
+ )
173
+ inv.add_argument("--include-node-modules", action="store_true")
174
+ inv.add_argument("--include-venvs", action="store_true")
175
+ inv.add_argument("--include-git", action="store_true")
176
+ inv.add_argument("--include-dist", action="store_true")
177
+ inv.add_argument("--follow-symlinks", action="store_true")
178
+ inv.set_defaults(func=_cmd_repository_inventory)
179
+
180
+ sb = repo_sub.add_parser(
181
+ "scan-bloat",
182
+ help="largest runtime source files (KB/lines/est. tokens)",
183
+ )
184
+ sb.add_argument("--root", default=".", help="directory to scan (default: cwd)")
185
+ sb.add_argument("--top", type=int, default=30)
186
+ sb.add_argument("--min-kb", type=float, default=0)
187
+ sb.add_argument(
188
+ "--ext",
189
+ default=".js,.ts,.jsx,.tsx,.mjs,.cjs",
190
+ help="comma-separated extensions",
191
+ )
192
+ sb.add_argument("--exclude", default="", help="extra dir names to exclude")
193
+ sb.add_argument("--output-dir")
194
+ sb.add_argument("--format", choices=["json", "markdown"], default="markdown")
195
+ sb.add_argument(
196
+ "--json-envelope",
197
+ action="store_true",
198
+ help="print ToolResult.data JSON only (agent/terminal capture)",
199
+ )
200
+ sb.set_defaults(func=_cmd_repository_scan_bloat)
201
+
202
+ return ap
203
+
204
+
205
+ def main(argv: list[str] | None = None) -> int:
206
+ parser = build_parser()
207
+ args = parser.parse_args(argv)
208
+ return args.func(args)
209
+
210
+
211
+ if __name__ == "__main__":
212
+ sys.exit(main())
File without changes
@@ -0,0 +1,157 @@
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
+ p = tool_input.params
117
+ prefix = p.get("prefix", "agentsam_")
118
+ output_dir = tool_input.output_path()
119
+
120
+ try:
121
+ adapter = D1Adapter.from_env(
122
+ db_name=p.get("db"), wrangler_config=p.get("config"), repo_root=p.get("repo_root")
123
+ )
124
+ tables = adapter.list_tables(like=f"{prefix}%")
125
+ walks = [_walk_table(adapter, t) for t in tables]
126
+ md = _render_markdown(walks, prefix)
127
+ json_payload = {
128
+ "prefix": prefix,
129
+ "database": adapter.db_name,
130
+ "table_count": len(walks),
131
+ "tables": [asdict(w) for w in walks],
132
+ }
133
+
134
+ artifacts: list[str] = []
135
+ if output_dir:
136
+ output_dir.mkdir(parents=True, exist_ok=True)
137
+ (output_dir / "agentsam-walk.md").write_text(md, encoding="utf-8")
138
+ (output_dir / "agentsam-walk.json").write_text(
139
+ __import__("json").dumps(json_payload, indent=2), encoding="utf-8"
140
+ )
141
+ artifacts = [str(output_dir / "agentsam-walk.md"), str(output_dir / "agentsam-walk.json")]
142
+
143
+ result = ToolResult(
144
+ ok=True, tool=TOOL_NAME, mode=tool_input.mode, request_id=tool_input.request_id,
145
+ started_at=started, finished_at=start_timer(),
146
+ summary=f"Walked {len(walks)} `{prefix}%` tables.",
147
+ data=json_payload, artifacts=artifacts,
148
+ )
149
+ except D1AdapterError as e:
150
+ result = ToolResult(
151
+ ok=False, tool=TOOL_NAME, mode=tool_input.mode, request_id=tool_input.request_id,
152
+ started_at=started, finished_at=start_timer(),
153
+ summary="D1 adapter error", error=str(e),
154
+ )
155
+
156
+ write_receipt(result, output_dir)
157
+ return result