makefile-agent 0.3.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.
make_agent/main.py ADDED
@@ -0,0 +1,210 @@
1
+ """make-agent: an AI agent driven by a Makefile."""
2
+
3
+ import argparse
4
+ import logging
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from make_agent.agent import _DEFAULT_MAX_TOKENS, _DEFAULT_MAX_TOOL_OUTPUT
9
+ from make_agent.builtin_tools import BUILTIN_TOOL_NAMES
10
+ from make_agent.agent_shell import run
11
+ from make_agent.app_dirs import default_agents_dir, log_file, project_dir
12
+ from make_agent.memory import Memory
13
+ from make_agent.settings import load_settings, run_setup_wizard
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _DEFAULT_MAKEFILE = "Makefile"
18
+
19
+
20
+ def _init_logging(debug: bool) -> None:
21
+ logging.basicConfig(filename=log_file(), level=logging.DEBUG if debug else logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
22
+
23
+
24
+ def _find_makefile(name: str = _DEFAULT_MAKEFILE) -> str | None:
25
+ """Search for *name* in order: cwd, then agents dir.
26
+
27
+ Returns the path string of the first existing file, or ``None`` if not found.
28
+ """
29
+ candidates = [
30
+ Path(name),
31
+ Path(default_agents_dir()) / Path(name).name,
32
+ ]
33
+ for candidate in candidates:
34
+ if candidate.exists():
35
+ return str(candidate)
36
+ return None
37
+
38
+
39
+ def _resolve_run_args(args: argparse.Namespace) -> argparse.Namespace:
40
+ """Apply settings.yaml defaults and run the setup wizard when appropriate.
41
+
42
+ Priority: CLI flag > settings.yaml > auto-discovered Makefile > code default.
43
+
44
+ When a makefile name comes from settings.yaml but is not found in the cwd,
45
+ the agents directory is also searched before giving up.
46
+
47
+ The wizard is triggered only when ``-f`` was not explicitly provided,
48
+ no ``settings.yaml`` exists for this project, *and* no Makefile is found
49
+ in the cwd or the project agents directory.
50
+ """
51
+ file_explicit = args.file is not None
52
+ model_explicit = args.model is not None
53
+
54
+ settings = load_settings()
55
+
56
+ if not file_explicit:
57
+ if settings is not None and "makefile" in settings:
58
+ # Search cwd then agents dir for the name from settings
59
+ found = _find_makefile(settings["makefile"])
60
+ args.file = found if found is not None else settings["makefile"]
61
+ else:
62
+ found = _find_makefile()
63
+ if found is not None:
64
+ args.file = found
65
+ elif settings is None:
66
+ settings = run_setup_wizard()
67
+ args.file = settings.get("makefile", _DEFAULT_MAKEFILE)
68
+ else:
69
+ args.file = settings.get("makefile", _DEFAULT_MAKEFILE)
70
+
71
+ if settings is None:
72
+ settings = {}
73
+
74
+ if not model_explicit:
75
+ args.model = settings.get("model")
76
+
77
+ # agent_model: CLI flag > settings.yaml > falls back to main model at runtime
78
+ if not getattr(args, "agent_model", None):
79
+ args.agent_model = settings.get("agent_model") or None
80
+
81
+ # Memory: CLI flag takes precedence, then settings.yaml
82
+ if not getattr(args, "with_memory", False):
83
+ args.with_memory = bool(settings.get("memory", False))
84
+
85
+ return args
86
+
87
+
88
+ def _parse_disabled_tools(value: str | None) -> frozenset[str]:
89
+ """Parse the --disable-builtin-tools value into a frozenset of tool names.
90
+
91
+ Accepts ``"all"`` or a comma-separated list of known built-in tool names.
92
+ Exits with an error on unknown names.
93
+ """
94
+ if not value:
95
+ return frozenset()
96
+ if value.strip().lower() == "all":
97
+ return BUILTIN_TOOL_NAMES
98
+ names = frozenset(n.strip() for n in value.split(",") if n.strip())
99
+ unknown = names - BUILTIN_TOOL_NAMES
100
+ if unknown:
101
+ sys.exit(f"make-agent: unknown built-in tool(s): {', '.join(sorted(unknown))}. "
102
+ f"Valid names: {', '.join(sorted(BUILTIN_TOOL_NAMES))}")
103
+ return names
104
+
105
+
106
+ def _cmd_run(args: argparse.Namespace) -> None:
107
+ args = _resolve_run_args(args)
108
+
109
+ if args.model is None:
110
+ sys.exit("make-agent: model is required — pass --model or set 'model' in settings.yaml")
111
+
112
+ prompt = args.prompt
113
+ if args.prompt_file is not None:
114
+ try:
115
+ prompt = Path(args.prompt_file).read_text(encoding="utf-8")
116
+ except OSError as e:
117
+ sys.exit(f"make-agent run: {e}")
118
+
119
+ memory: Memory | None = None
120
+ if args.with_memory:
121
+ db_path = project_dir() / "memory.db"
122
+ memory = Memory(db_path)
123
+
124
+ run(
125
+ makefile_path=Path(args.file),
126
+ model=args.model,
127
+ prompt=prompt,
128
+ debug=args.debug,
129
+ max_retries=args.max_retries,
130
+ tool_timeout=args.tool_timeout,
131
+ max_tool_output=args.max_tool_output,
132
+ max_tokens=args.max_tokens,
133
+ agents_dir=args.agents_dir,
134
+ memory=memory,
135
+ disabled_builtin_tools=_parse_disabled_tools(args.disable_builtin_tools),
136
+ agent_model=args.agent_model,
137
+ )
138
+
139
+
140
+ def main() -> None:
141
+ parser = argparse.ArgumentParser(
142
+ prog="make-agent",
143
+ description="An AI agent that reads its system prompt and tools from a Makefile.",
144
+ )
145
+ subparsers = parser.add_subparsers(dest="command")
146
+
147
+ # ── run (default) ────────────────────────────────────────────────────────
148
+ run_p = subparsers.add_parser("run", help="Start the interactive agent (default)")
149
+ run_p.add_argument("-f", "--file", default=None, metavar="FILE", help="Makefile to load (default: ./Makefile or value from settings.yaml)")
150
+ run_p.add_argument("--model", default=None, metavar="MODEL", help="any-llm model string (required if not set in settings.yaml)")
151
+ run_prompt_g = run_p.add_mutually_exclusive_group()
152
+ run_prompt_g.add_argument("--prompt", default=None, metavar="PROMPT", help="Skip interactive mode and send this prompt to the model")
153
+ run_prompt_g.add_argument("--prompt-file", default=None, metavar="FILE", help="Skip interactive mode and read the prompt from FILE")
154
+ run_p.add_argument("--debug", action="store_true", default=False, help="Log all messages to make-agent.log")
155
+ run_p.add_argument("--max-retries", type=int, default=5, metavar="N", help="Max retry attempts on rate limit (default: 5)")
156
+ run_p.add_argument("--tool-timeout", type=int, default=600, metavar="SECONDS", help="Timeout in seconds for each tool call (default: 600)")
157
+ run_p.add_argument("--agents-dir", default=None, metavar="DIR", help="Directory for specialist agent .mk files (default: ~/.make-agent/<project>/agents/)")
158
+ run_p.add_argument(
159
+ "--max-tool-output",
160
+ type=int,
161
+ default=_DEFAULT_MAX_TOOL_OUTPUT,
162
+ metavar="CHARS",
163
+ help=f"Max characters of stdout kept from each tool call; 0 = unlimited (default: {_DEFAULT_MAX_TOOL_OUTPUT})",
164
+ )
165
+ run_p.add_argument(
166
+ "--max-tokens",
167
+ type=int,
168
+ default=_DEFAULT_MAX_TOKENS,
169
+ metavar="N",
170
+ help=f"Max tokens in model response (default: {_DEFAULT_MAX_TOKENS})",
171
+ )
172
+ run_p.add_argument(
173
+ "--with-memory", action="store_true", default=False, help="Enable persistent conversation memory (stored in ~/.make-agent/<project>/memory.db)"
174
+ )
175
+ run_p.add_argument(
176
+ "--disable-builtin-tools",
177
+ default=None,
178
+ metavar="TOOLS",
179
+ help=f"Comma-separated built-in tool names to disable, or 'all'. Valid names: {', '.join(sorted(BUILTIN_TOOL_NAMES))}",
180
+ )
181
+ run_p.add_argument(
182
+ "--agent-model",
183
+ default=None,
184
+ metavar="MODEL",
185
+ help="Model used when running specialist agents via run_agent (default: same as --model)",
186
+ )
187
+
188
+ # ── legacy: no subcommand → behave as "run" ──────────────────────────────
189
+ parser.add_argument("-f", "--file", default=None, metavar="FILE", help=argparse.SUPPRESS)
190
+ parser.add_argument("--model", default=None, metavar="MODEL", help=argparse.SUPPRESS)
191
+ legacy_prompt_g = parser.add_mutually_exclusive_group()
192
+ legacy_prompt_g.add_argument("--prompt", default=None, metavar="PROMPT", help=argparse.SUPPRESS)
193
+ legacy_prompt_g.add_argument("--prompt-file", default=None, metavar="FILE", help=argparse.SUPPRESS)
194
+ parser.add_argument("--debug", action="store_true", default=False, help=argparse.SUPPRESS)
195
+ parser.add_argument("--max-retries", type=int, default=5, metavar="N", help=argparse.SUPPRESS)
196
+ parser.add_argument("--tool-timeout", type=int, default=600, metavar="SECONDS", help=argparse.SUPPRESS)
197
+ parser.add_argument("--agents-dir", default=None, metavar="DIR", help=argparse.SUPPRESS)
198
+ parser.add_argument("--max-tool-output", type=int, default=_DEFAULT_MAX_TOOL_OUTPUT, metavar="CHARS", help=argparse.SUPPRESS)
199
+ parser.add_argument("--max-tokens", type=int, default=_DEFAULT_MAX_TOKENS, metavar="N", help=argparse.SUPPRESS)
200
+ parser.add_argument("--with-memory", action="store_true", default=False, help=argparse.SUPPRESS)
201
+ parser.add_argument("--disable-builtin-tools", default=None, metavar="TOOLS", help=argparse.SUPPRESS)
202
+ parser.add_argument("--agent-model", default=None, metavar="MODEL", help=argparse.SUPPRESS)
203
+
204
+ args = parser.parse_args()
205
+ _init_logging(args.debug)
206
+ _cmd_run(args)
207
+
208
+
209
+ if __name__ == "__main__":
210
+ main()
make_agent/memory.py ADDED
@@ -0,0 +1,170 @@
1
+ """Persistent conversation memory backed by SQLite with FTS5 full-text search.
2
+
3
+ The database lives at ``~/.make-agent/<project-slug>/memory.db``.
4
+
5
+ Schema overview:
6
+ - ``messages`` — base table (id, created_at, sender, message)
7
+ - ``messages_fts`` — FTS5 content table over ``messages``
8
+ - ``user_memory`` — view: messages WHERE sender = 'user'
9
+ - ``agent_memory`` — view: messages WHERE sender = 'agent'
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sqlite3
15
+ from pathlib import Path
16
+
17
+ _SCHEMA_STATEMENTS = [
18
+ """
19
+ CREATE TABLE IF NOT EXISTS messages (
20
+ id INTEGER PRIMARY KEY,
21
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
22
+ sender TEXT NOT NULL CHECK(sender IN ('user', 'agent')),
23
+ message TEXT NOT NULL
24
+ )
25
+ """,
26
+ """
27
+ CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
28
+ message,
29
+ content='messages',
30
+ content_rowid='id'
31
+ )
32
+ """,
33
+ """
34
+ CREATE VIEW IF NOT EXISTS user_memory AS
35
+ SELECT * FROM messages WHERE sender = 'user'
36
+ """,
37
+ """
38
+ CREATE VIEW IF NOT EXISTS agent_memory AS
39
+ SELECT * FROM messages WHERE sender = 'agent'
40
+ """,
41
+ """
42
+ CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
43
+ INSERT INTO messages_fts(rowid, message) VALUES (new.id, new.message);
44
+ END
45
+ """,
46
+ """
47
+ CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
48
+ INSERT INTO messages_fts(messages_fts, rowid, message)
49
+ VALUES ('delete', old.id, old.message);
50
+ END
51
+ """,
52
+ """
53
+ CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
54
+ INSERT INTO messages_fts(messages_fts, rowid, message)
55
+ VALUES ('delete', old.id, old.message);
56
+ INSERT INTO messages_fts(rowid, message) VALUES (new.id, new.message);
57
+ END
58
+ """,
59
+ ]
60
+
61
+
62
+ class Memory:
63
+ """Persistent agent memory stored in a SQLite database.
64
+
65
+ The database and schema are created lazily on first use.
66
+ """
67
+
68
+ def __init__(self, db_path: Path) -> None:
69
+ self._db_path = db_path
70
+ self._conn: sqlite3.Connection | None = None
71
+
72
+ def _get_conn(self) -> sqlite3.Connection:
73
+ if self._conn is None:
74
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
75
+ self._conn = sqlite3.connect(str(self._db_path))
76
+ self._conn.row_factory = sqlite3.Row
77
+ for stmt in _SCHEMA_STATEMENTS:
78
+ self._conn.execute(stmt)
79
+ self._conn.commit()
80
+ return self._conn
81
+
82
+ def store(self, sender: str, message: str) -> None:
83
+ """Store a message from *sender* (``'user'`` or ``'agent'``)."""
84
+ conn = self._get_conn()
85
+ conn.execute(
86
+ "INSERT INTO messages (sender, message) VALUES (?, ?)",
87
+ (sender, message),
88
+ )
89
+ conn.commit()
90
+
91
+ def _search(
92
+ self,
93
+ view: str,
94
+ query: str,
95
+ limit: int = 10,
96
+ from_date: str | None = None,
97
+ to_date: str | None = None,
98
+ ) -> str:
99
+ conn = self._get_conn()
100
+ sql = f"""
101
+ SELECT v.created_at, v.message
102
+ FROM {view} v
103
+ JOIN messages_fts ON v.id = messages_fts.rowid
104
+ WHERE messages_fts MATCH ?
105
+ """
106
+ params: list = [query]
107
+ if from_date:
108
+ sql += " AND v.created_at >= ?"
109
+ params.append(from_date)
110
+ if to_date:
111
+ sql += " AND v.created_at <= ?"
112
+ params.append(to_date)
113
+ sql += " ORDER BY rank LIMIT ?"
114
+ params.append(limit)
115
+
116
+ rows = conn.execute(sql, params).fetchall()
117
+ if not rows:
118
+ return "No results found."
119
+ return "\n".join(f"[{row['created_at']}] {row['message']}" for row in rows)
120
+
121
+ def search_user(
122
+ self,
123
+ query: str,
124
+ limit: int = 10,
125
+ from_date: str | None = None,
126
+ to_date: str | None = None,
127
+ ) -> str:
128
+ """Search past user messages using FTS5 via the ``user_memory`` view."""
129
+ return self._search("user_memory", query, limit, from_date, to_date)
130
+
131
+ def search_agent(
132
+ self,
133
+ query: str,
134
+ limit: int = 10,
135
+ from_date: str | None = None,
136
+ to_date: str | None = None,
137
+ ) -> str:
138
+ """Search past agent replies using FTS5 via the ``agent_memory`` view."""
139
+ return self._search("agent_memory", query, limit, from_date, to_date)
140
+
141
+ def recent(
142
+ self,
143
+ limit: int = 10,
144
+ from_date: str | None = None,
145
+ to_date: str | None = None,
146
+ ) -> str:
147
+ """Return the *limit* most recent messages, optionally filtered by date range."""
148
+ conn = self._get_conn()
149
+ sql = "SELECT created_at, sender, message FROM messages WHERE 1=1"
150
+ params: list = []
151
+ if from_date:
152
+ sql += " AND created_at >= ?"
153
+ params.append(from_date)
154
+ if to_date:
155
+ sql += " AND created_at <= ?"
156
+ params.append(to_date)
157
+ sql += " ORDER BY id DESC LIMIT ?"
158
+ params.append(limit)
159
+
160
+ rows = conn.execute(sql, params).fetchall()
161
+ if not rows:
162
+ return "No messages found."
163
+ rows = list(reversed(rows))
164
+ return "\n".join(f"[{row['created_at']}] {row['sender']}: {row['message']}" for row in rows)
165
+
166
+ def close(self) -> None:
167
+ """Close the underlying database connection."""
168
+ if self._conn is not None:
169
+ self._conn.close()
170
+ self._conn = None