squawkit 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.
squawkit/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """squawkit: Semi-QUalified Agent Wingman Kit — the stateful intelligence + MCP server layer for fledgling-equipped agents."""
2
+
3
+ __version__ = "0.1.0"
squawkit/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Run fledgling MCP server: python -m squawkit"""
2
+
3
+ from squawkit.server import main
4
+
5
+ main()
squawkit/db.py ADDED
@@ -0,0 +1,17 @@
1
+ """DuckDB connection for squawkit.
2
+
3
+ Thin wrapper over pluckit's Plucker that returns a fledgling-enabled
4
+ connection proxy with auto-generated macro wrappers.
5
+ """
6
+
7
+ from pluckit import Plucker
8
+
9
+
10
+ def create_connection(**kwargs):
11
+ """Create a fledgling-enabled DuckDB connection via pluckit.
12
+
13
+ Accepts the same kwargs as :class:`pluckit.Plucker` (``repo``,
14
+ ``profile``, ``modules``, ``init``). Returns the fledgling Connection
15
+ proxy — the same object squawkit's server.py uses as ``con``.
16
+ """
17
+ return Plucker(**kwargs).connection
squawkit/defaults.py ADDED
@@ -0,0 +1,225 @@
1
+ """Smart project-aware defaults for fledgling tools.
2
+
3
+ Infers sensible default patterns (code globs, doc paths, git revisions)
4
+ from the project at server startup. Users can override via
5
+ .fledgling-python/config.toml. Explicit tool parameters always win.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import subprocess
12
+ import tomllib
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+ if TYPE_CHECKING:
20
+ from duckdb import DuckDBPyConnection as Connection
21
+
22
+
23
+ @dataclass
24
+ class ProjectDefaults:
25
+ """Inferred at server startup, cached for the session."""
26
+
27
+ code_pattern: str = "**/*"
28
+ doc_pattern: str = "**/*.md"
29
+ main_branch: str = "main"
30
+ from_rev: str = "HEAD~1"
31
+ to_rev: str = "HEAD"
32
+ languages: list[str] = field(default_factory=list)
33
+
34
+ def scoped_code_pattern(self, path: str | Path) -> str:
35
+ """Scope the code pattern to a subdirectory path."""
36
+ filename_glob = self.code_pattern.rsplit("/", 1)[-1]
37
+ return str(Path(path) / "**" / filename_glob)
38
+
39
+
40
+ def apply_defaults(
41
+ defaults: ProjectDefaults,
42
+ tool_name: str,
43
+ kwargs: dict[str, object],
44
+ ) -> dict[str, object]:
45
+ """Substitute None params with smart defaults for a given tool.
46
+
47
+ Returns a new dict — does not mutate the input.
48
+ """
49
+ mapping = TOOL_DEFAULTS.get(tool_name)
50
+ if not mapping:
51
+ return dict(kwargs)
52
+ result = dict(kwargs)
53
+ for param, field_name in mapping.items():
54
+ if result.get(param) is None:
55
+ result[param] = getattr(defaults, field_name)
56
+ return result
57
+
58
+
59
+ def load_config(root: str | Path) -> dict[str, str]:
60
+ """Read defaults overrides from .fledgling-python/config.toml.
61
+
62
+ Returns the [defaults] section as a flat dict, or {} if the file
63
+ doesn't exist or has no [defaults] section.
64
+ """
65
+ config_path = Path(root) / ".fledgling-python" / "config.toml"
66
+ if not config_path.is_file():
67
+ return {}
68
+ with open(config_path, "rb") as f:
69
+ data = tomllib.load(f)
70
+ return dict(data.get("defaults", {}))
71
+
72
+
73
+ # Tool name → {param_name: defaults_field_name}
74
+ TOOL_DEFAULTS: dict[str, dict[str, str]] = {
75
+ "find_definitions": {"file_pattern": "code_pattern"},
76
+ "find_in_ast": {"file_pattern": "code_pattern"},
77
+ "code_structure": {"file_pattern": "code_pattern"},
78
+ "complexity_hotspots": {"file_pattern": "code_pattern"},
79
+ "changed_function_summary": {"file_pattern": "code_pattern"},
80
+ "doc_outline": {"file_pattern": "doc_pattern"},
81
+ "file_changes": {"from_rev": "from_rev", "to_rev": "to_rev"},
82
+ "file_diff": {"from_rev": "from_rev", "to_rev": "to_rev"},
83
+ "structural_diff": {"from_rev": "from_rev", "to_rev": "to_rev"},
84
+ }
85
+
86
+
87
+ # ── Language detection ──────────────────────────────────────────────
88
+
89
+ # Language name (as returned by project_overview) → file extensions.
90
+ # Hardcoded for now; will be replaced by sitting_duck's extension listing
91
+ # when available.
92
+ LANGUAGE_EXTENSIONS: dict[str, list[str]] = {
93
+ "Python": ["py", "pyi"],
94
+ "JavaScript": ["js", "jsx", "mjs"],
95
+ "TypeScript": ["ts", "tsx"],
96
+ "Rust": ["rs"],
97
+ "Go": ["go"],
98
+ "Java": ["java"],
99
+ "Ruby": ["rb"],
100
+ "C": ["c"],
101
+ "C++": ["cpp", "cc"],
102
+ "C/C++": ["h", "hpp"],
103
+ "SQL": ["sql"],
104
+ "Shell": ["sh", "bash", "zsh"],
105
+ "Kotlin": ["kt", "kts"],
106
+ "Swift": ["swift"],
107
+ "Dart": ["dart"],
108
+ "PHP": ["php"],
109
+ "Lua": ["lua"],
110
+ "Zig": ["zig"],
111
+ "R": ["r", "R"],
112
+ "C#": ["cs"],
113
+ "HCL": ["hcl", "tf"],
114
+ }
115
+
116
+ # Directories to check for docs, in priority order.
117
+ _DOC_DIRS = ["docs", "documentation", "doc", "wiki"]
118
+
119
+
120
+ def _code_glob(extensions: list[str]) -> str:
121
+ """Build a glob pattern from a list of extensions.
122
+
123
+ Uses only the primary (first) extension because DuckDB's glob()
124
+ does not support brace expansion (e.g. ``**/*.{py,pyi}``).
125
+ """
126
+ return f"**/*.{extensions[0]}"
127
+
128
+
129
+ def _find_doc_dir(con: Connection, root: str | Path) -> str | None:
130
+ """Check for common doc directories under `root` using list_files.
131
+
132
+ Uses an absolute glob (``{root}/{d}/*``) so the probe doesn't depend
133
+ on the connection's ``session_root`` matching the caller's intended
134
+ project root. The previous relative-pattern form silently failed when
135
+ ``session_root`` and the ``root`` parameter diverged, causing
136
+ doc_pattern to fall back to ``**/*.md``.
137
+ """
138
+ root_path = Path(root)
139
+ for d in _DOC_DIRS:
140
+ try:
141
+ pattern = str(root_path / d / "*")
142
+ rows = con.list_files(pattern).fetchall()
143
+ if rows:
144
+ return d
145
+ except Exception:
146
+ log.debug("doc dir probe failed for %s", d, exc_info=True)
147
+ continue
148
+ return None
149
+
150
+
151
+ def _infer_main_branch(root: str | Path) -> str:
152
+ """Detect the default branch from git remote HEAD."""
153
+ try:
154
+ result = subprocess.run(
155
+ ["git", "rev-parse", "--abbrev-ref", "origin/HEAD"],
156
+ capture_output=True, text=True, cwd=str(Path(root)), timeout=5,
157
+ )
158
+ if result.returncode == 0:
159
+ # Output is "origin/main" or "origin/master" — strip prefix
160
+ branch = result.stdout.strip().removeprefix("origin/")
161
+ if branch:
162
+ return branch
163
+ except Exception:
164
+ log.debug("git main branch detection failed", exc_info=True)
165
+ return "main"
166
+
167
+
168
+ def infer_defaults(
169
+ con: Connection,
170
+ overrides: dict[str, str] | None = None,
171
+ root: str | Path | None = None,
172
+ ) -> ProjectDefaults:
173
+ """Analyze the project and build smart defaults.
174
+
175
+ Args:
176
+ con: A fledgling Connection to the project.
177
+ overrides: Values from config file that override inference.
178
+ root: Project root for git operations. Defaults to cwd.
179
+
180
+ Returns:
181
+ ProjectDefaults with inferred + overridden values.
182
+ """
183
+ overrides = overrides or {}
184
+
185
+ # ── Code pattern ────────────────────────────────────────────
186
+ code_pattern = "**/*"
187
+ languages: list[str] = []
188
+ try:
189
+ rows = con.project_overview().fetchall()
190
+ # rows are (language, extension, file_count) ordered by count DESC
191
+ if rows:
192
+ # Group by language, sum file counts
193
+ lang_counts: dict[str, int] = {}
194
+ for lang, _ext, count in rows:
195
+ lang_counts[lang] = lang_counts.get(lang, 0) + count
196
+ languages = sorted(lang_counts.keys())
197
+ # Find the top language that we have extension mappings for
198
+ ranked = sorted(lang_counts, key=lang_counts.get, reverse=True) # type: ignore[arg-type]
199
+ for lang in ranked:
200
+ if lang in LANGUAGE_EXTENSIONS:
201
+ code_pattern = _code_glob(LANGUAGE_EXTENSIONS[lang])
202
+ break
203
+ except Exception:
204
+ log.debug("project_overview inference failed", exc_info=True)
205
+
206
+ # ── Doc pattern ─────────────────────────────────────────────
207
+ doc_dir = _find_doc_dir(con, root or ".")
208
+ doc_pattern = str(Path(doc_dir) / "**" / "*.md") if doc_dir else "**/*.md"
209
+
210
+ # ── Main branch ─────────────────────────────────────────────
211
+ main_branch = _infer_main_branch(root or ".")
212
+
213
+ # ── Build defaults, apply overrides ─────────────────────────
214
+ defaults = ProjectDefaults(
215
+ code_pattern=code_pattern,
216
+ doc_pattern=doc_pattern,
217
+ main_branch=main_branch,
218
+ languages=languages,
219
+ )
220
+
221
+ for key, value in overrides.items():
222
+ if hasattr(defaults, key):
223
+ setattr(defaults, key, value)
224
+
225
+ return defaults
squawkit/formatting.py ADDED
@@ -0,0 +1,89 @@
1
+ """Shared formatting and truncation helpers for fledgling.
2
+
3
+ Used by both server.py (auto-registered tools) and workflows.py
4
+ (compound tools) without circular imports.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ # ── Truncation config ──────────────────────────────────────────────
11
+ # These dicts live here so _truncate_rows can reference them without
12
+ # importing server.py.
13
+
14
+ _MAX_LINES = {
15
+ "read_source": 200,
16
+ "read_context": 50,
17
+ "file_diff": 300,
18
+ "file_at_version": 200,
19
+ }
20
+
21
+ _MAX_ROWS = {
22
+ "find_definitions": 50,
23
+ "find_in_ast": 50,
24
+ "list_files": 100,
25
+ "doc_outline": 50,
26
+ "file_changes": 25,
27
+ "recent_changes": 20,
28
+ }
29
+
30
+ _HINTS = {
31
+ "read_source": "Use lines='N-M' to see a range, or match='keyword' to filter.",
32
+ "read_context": "Use a smaller context window or match='keyword' to filter.",
33
+ "file_diff": "Use a narrower revision range.",
34
+ "file_at_version": "Use lines='N-M' to see a range.",
35
+ "find_definitions": "Use name_pattern='%keyword%' to narrow, or file_pattern to scope.",
36
+ "find_in_ast": "Use name='keyword' to narrow results.",
37
+ "list_files": "Use a more specific glob pattern.",
38
+ "doc_outline": "Use search='keyword' to filter.",
39
+ "file_changes": "Use a narrower revision range.",
40
+ "recent_changes": "Use a smaller count.",
41
+ }
42
+
43
+ _HEAD_TAIL = 5 # rows to show at each end of truncated output
44
+
45
+
46
+ def _truncate_rows(rows, max_rows, macro_name):
47
+ """Truncate rows to head + tail with an omission message.
48
+
49
+ Returns (display_rows, omission_line) where omission_line is None
50
+ if no truncation occurred.
51
+ """
52
+ total = len(rows)
53
+ if max_rows <= 0 or total <= max_rows:
54
+ return rows, None
55
+ # Not enough rows for a clean head/tail split — return all
56
+ if total <= 2 * _HEAD_TAIL:
57
+ return rows, None
58
+ head = rows[:_HEAD_TAIL]
59
+ tail = rows[-_HEAD_TAIL:]
60
+ omitted = total - 2 * _HEAD_TAIL
61
+ hint = _HINTS.get(macro_name, "")
62
+ unit = "lines" if macro_name in _MAX_LINES else "rows"
63
+ msg = f"--- omitted {omitted} of {total} {unit} ---"
64
+ if hint:
65
+ msg += f"\n{hint}"
66
+ return head + tail, msg
67
+
68
+
69
+ def _format_markdown_table(cols: list[str], rows: list[tuple]) -> str:
70
+ """Format query results as a markdown table."""
71
+ # Calculate column widths
72
+ widths = [len(c) for c in cols]
73
+ str_rows = []
74
+ for row in rows:
75
+ str_row = [str(v) if v is not None else "" for v in row]
76
+ str_rows.append(str_row)
77
+ for i, v in enumerate(str_row):
78
+ widths[i] = max(widths[i], len(v))
79
+
80
+ # Build table
81
+ lines = []
82
+ header = "| " + " | ".join(c.ljust(widths[i]) for i, c in enumerate(cols)) + " |"
83
+ sep = "|-" + "-|-".join("-" * widths[i] for i in range(len(cols))) + "-|"
84
+ lines.append(header)
85
+ lines.append(sep)
86
+ for str_row in str_rows:
87
+ line = "| " + " | ".join(v.ljust(widths[i]) for i, v in enumerate(str_row)) + " |"
88
+ lines.append(line)
89
+ return "\n".join(lines)
squawkit/prompts.py ADDED
@@ -0,0 +1,236 @@
1
+ """Fledgling: MCP prompt templates.
2
+
3
+ Parameterized workflow instructions with live project data pre-filled.
4
+ Each prompt calls the corresponding P4-004 workflow function to gather
5
+ context, then embeds it into condensed instructions derived from the
6
+ skill guides in skills/.
7
+
8
+ The agent uses prompts to learn *how* to approach a task; it uses
9
+ compound tools to get the *information* for the task.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from typing import TYPE_CHECKING
16
+
17
+ from squawkit.workflows import explore, investigate, review
18
+
19
+ if TYPE_CHECKING:
20
+ from fastmcp import FastMCP
21
+ from duckdb import DuckDBPyConnection as Connection
22
+ from squawkit.defaults import ProjectDefaults
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+
27
+ def _escape_braces(value: str) -> str:
28
+ """Escape curly braces so user input is safe for str.format()."""
29
+ return value.replace("{", "{{").replace("}", "}}")
30
+
31
+
32
+ # ── Templates ─────────────────────────────────────────────────────
33
+ # Condensed from skills/*.md — actionable steps with {briefing}
34
+ # placeholder for live data and tool suggestions pre-filled with
35
+ # the project's inferred patterns.
36
+
37
+ EXPLORE_TEMPLATE = """\
38
+ ## Explore Codebase
39
+
40
+ You are exploring {scope}. Below is a pre-loaded briefing followed by \
41
+ a workflow for deeper exploration.
42
+
43
+ ### Project Briefing
44
+
45
+ {briefing}
46
+
47
+ ### Exploration Workflow
48
+
49
+ Work top-down through these phases:
50
+
51
+ **Phase 1: Landscape** (loaded above)
52
+ Review the Languages, Key Definitions, and Documentation sections. \
53
+ Note the dominant language and most complex definitions.
54
+
55
+ **Phase 2: Architecture**
56
+ - `CodeStructure('{code_pattern}')` — definitions per file, complexity
57
+ - `FindDefinitions('{code_pattern}')` — functions, classes, modules
58
+
59
+ **Phase 3: Dependencies**
60
+ - `FindInAST('{code_pattern}', 'imports')` — external dependencies
61
+ - `FindInAST('{code_pattern}', 'calls')` — internal call graph
62
+
63
+ **Phase 4: History**
64
+ - `recent_changes(20)` — commit history
65
+ - `GitDiffSummary(from_rev='HEAD~10', to_rev='HEAD')` — changed files
66
+ - `changed_function_summary('HEAD~10', 'HEAD', '{code_pattern}')` — semantic changes
67
+
68
+ **Phase 5: Deep Dive**
69
+ - `ReadLines(file_path='...', lines='42-60')` — specific range
70
+ - `MDSection(file_path='...', section_id='...')` — doc sections
71
+
72
+ ### Anti-Patterns
73
+ - Use `ReadLines` with line ranges, not `cat` or `head`
74
+ - Use `FindDefinitions` then `ReadLines`, not `grep -r`
75
+ - Use `list_files` with globs, not `find . -name`
76
+ """
77
+
78
+ INVESTIGATE_TEMPLATE = """\
79
+ ## Investigate Issue
80
+
81
+ You are investigating: **{symptom}**
82
+
83
+ ### Initial Findings
84
+
85
+ {briefing}
86
+
87
+ ### Investigation Workflow
88
+
89
+ **Step 1: Locate** (findings above)
90
+ Review the definitions and source above. If not found, try:
91
+ - `FindDefinitions('{code_pattern}', '%{symptom}%')` — broader search
92
+ - `ReadLines(file_path='...', match='{symptom}')` — grep-like search
93
+
94
+ **Step 2: Understand the Code**
95
+ - `ReadLines(file_path='...', lines='42', ctx='10')` — context around a line
96
+ - `CodeStructure('...')` — file overview with complexity
97
+
98
+ **Step 3: Check History**
99
+ - `GitDiffSummary(from_rev='HEAD~20', to_rev='HEAD')` — what files changed
100
+ - `GitDiffFile(file='...', from_rev='HEAD~5', to_rev='HEAD')` — line-level diff
101
+ - `changed_function_summary('HEAD~10', 'HEAD', '{code_pattern}')` — complexity changes
102
+
103
+ **Step 4: Trace Dependencies**
104
+ - `FindInAST('...', 'imports')` — external dependencies
105
+ - `FindInAST('...', 'calls')` — function calls made
106
+
107
+ **Step 5: Check Related Code**
108
+ - `FindDefinitions('{code_pattern}', '%similar_name%')` — related functions
109
+ - `function_callers('{code_pattern}', 'func_name')` — callers
110
+
111
+ ### Key Principles
112
+ - Locate before reading — find the right file and line first
113
+ - History is data — check what changed recently
114
+ - Compose queries — use the query tool for complex joins
115
+ """
116
+
117
+ REVIEW_TEMPLATE = """\
118
+ ## Review Changes
119
+
120
+ You are reviewing: **{rev_range}**
121
+
122
+ ### Change Summary
123
+
124
+ {briefing}
125
+
126
+ ### Review Checklist
127
+
128
+ **Step 1: File-Level Overview** (loaded above)
129
+ Review Changed Files. Prioritize largest changes; note new/deleted files.
130
+
131
+ **Step 2: Function-Level Analysis** (loaded above)
132
+ Review Changed Functions. Focus on:
133
+ - High-complexity new functions (need most scrutiny)
134
+ - Functions where complexity increased
135
+ - Deleted functions (check for orphaned callers)
136
+
137
+ **Step 3: Read Diffs** (top diffs loaded above)
138
+ For additional files:
139
+ - `GitDiffFile(file='...', from_rev='{from_rev}', to_rev='{to_rev}')`
140
+
141
+ **Step 4: Understand Context**
142
+ - `ReadLines(file_path='...', lines='42', ctx='15')` — surrounding code
143
+ - `CodeStructure('...')` — file structure
144
+
145
+ **Step 5: Check Impact**
146
+ - `function_callers('{code_pattern}', 'func_name')` — callers of changed functions
147
+ - `FindInAST('...', 'imports')` — dependency changes
148
+
149
+ **Step 6: Compare Versions**
150
+ - `GitShow(file='...', rev='{from_rev}')` — old implementation
151
+
152
+ ### Review Quality Checks
153
+ - New functions without tests? Check `changed_function_summary` where change_status='added'
154
+ - Complexity increases above 5? Inspect those functions
155
+ - Large new files (>5000 bytes)? Consider splitting
156
+ """
157
+
158
+
159
+ # ── Registration ──────────────────────────────────────────────────
160
+
161
+
162
+ def register_prompts(mcp: FastMCP, con: Connection, defaults: ProjectDefaults):
163
+ """Register MCP prompt templates on the FastMCP server."""
164
+
165
+ @mcp.prompt(
166
+ name="explore",
167
+ description="Exploration workflow with live project data: languages, "
168
+ "key definitions, docs, recent activity, and step-by-step "
169
+ "guidance for deeper exploration.",
170
+ )
171
+ def explore_prompt(path: str | None = None) -> str:
172
+ scope = f"path: {path}" if path else "the full project"
173
+ code_pattern = (
174
+ defaults.scoped_code_pattern(path)
175
+ if path else defaults.code_pattern
176
+ )
177
+ # Escape user input for safe str.format() interpolation
178
+ scope = _escape_braces(scope)
179
+ code_pattern = _escape_braces(code_pattern)
180
+ try:
181
+ briefing = explore(con, defaults, path=path)
182
+ except Exception:
183
+ log.debug("explore prompt: data gathering failed", exc_info=True)
184
+ briefing = "(Project data could not be loaded. Use the tools below to gather context manually.)"
185
+
186
+ return EXPLORE_TEMPLATE.format(
187
+ scope=scope,
188
+ briefing=briefing,
189
+ code_pattern=code_pattern,
190
+ )
191
+
192
+ @mcp.prompt(
193
+ name="investigate",
194
+ description="Investigation workflow with pre-found definitions and "
195
+ "source code for a symptom (error message, function name, "
196
+ "or file path), plus step-by-step debugging guidance.",
197
+ )
198
+ def investigate_prompt(symptom: str) -> str:
199
+ code_pattern = defaults.code_pattern
200
+ try:
201
+ briefing = investigate(con, defaults, name=symptom)
202
+ except Exception:
203
+ log.debug("investigate prompt: data gathering failed", exc_info=True)
204
+ briefing = f"(Could not find data for '{symptom}'. Use the tools below to search manually.)"
205
+
206
+ return INVESTIGATE_TEMPLATE.format(
207
+ symptom=_escape_braces(symptom),
208
+ briefing=briefing,
209
+ code_pattern=_escape_braces(code_pattern),
210
+ )
211
+
212
+ @mcp.prompt(
213
+ name="review",
214
+ description="Code review checklist with pre-loaded change summary, "
215
+ "complexity deltas, and diffs, plus step-by-step review "
216
+ "guidance.",
217
+ )
218
+ def review_prompt(from_rev: str | None = None, to_rev: str | None = None) -> str:
219
+ effective_from = from_rev or defaults.from_rev
220
+ effective_to = to_rev or defaults.to_rev
221
+ rev_range = f"{effective_from}..{effective_to}"
222
+ code_pattern = defaults.code_pattern
223
+
224
+ try:
225
+ briefing = review(con, defaults, from_rev=from_rev, to_rev=to_rev)
226
+ except Exception:
227
+ log.debug("review prompt: data gathering failed", exc_info=True)
228
+ briefing = "(Change data could not be loaded. Use the tools below to gather context manually.)"
229
+
230
+ return REVIEW_TEMPLATE.format(
231
+ rev_range=rev_range,
232
+ briefing=briefing,
233
+ from_rev=effective_from,
234
+ to_rev=effective_to,
235
+ code_pattern=code_pattern,
236
+ )