seedagent 0.7.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.
- coding_agent/__init__.py +169 -0
- coding_agent/__main__.py +5 -0
- coding_agent/backups.py +89 -0
- coding_agent/certificates.py +55 -0
- coding_agent/cleanup.py +128 -0
- coding_agent/cli.py +201 -0
- coding_agent/common.py +190 -0
- coding_agent/config.py +375 -0
- coding_agent/context.py +280 -0
- coding_agent/conversation.py +51 -0
- coding_agent/credentials.py +56 -0
- coding_agent/diagnose.py +81 -0
- coding_agent/errors.py +106 -0
- coding_agent/guard.py +97 -0
- coding_agent/history.py +68 -0
- coding_agent/index.py +390 -0
- coding_agent/logs.py +133 -0
- coding_agent/loop.py +301 -0
- coding_agent/memories.py +98 -0
- coding_agent/memory.py +210 -0
- coding_agent/ocr.py +78 -0
- coding_agent/prompts.py +128 -0
- coding_agent/schemas.py +887 -0
- coding_agent/session.py +121 -0
- coding_agent/signin.py +85 -0
- coding_agent/skills/ai-engineer/SKILL.md +283 -0
- coding_agent/skills/azure-cloud-architect/SKILL.md +112 -0
- coding_agent/skills/backend-engineer/SKILL.md +98 -0
- coding_agent/skills/database-architect/SKILL.md +85 -0
- coding_agent/skills/frontend-engineer/SKILL.md +113 -0
- coding_agent/skills/web-browsing/SKILL.md +62 -0
- coding_agent/skills.py +137 -0
- coding_agent/state.py +94 -0
- coding_agent/tools/__init__.py +181 -0
- coding_agent/tools/browser.py +238 -0
- coding_agent/tools/documents.py +814 -0
- coding_agent/tools/excel_add.py +309 -0
- coding_agent/tools/excel_look.py +364 -0
- coding_agent/tools/excel_script.py +236 -0
- coding_agent/tools/excel_xl.py +370 -0
- coding_agent/tools/files.py +391 -0
- coding_agent/tools/git.py +83 -0
- coding_agent/tools/interaction.py +17 -0
- coding_agent/tools/mail.py +263 -0
- coding_agent/tools/network.py +190 -0
- coding_agent/tools/office.py +151 -0
- coding_agent/tools/python_runner.py +87 -0
- coding_agent/tools/sms.py +86 -0
- coding_agent/tools/web.py +619 -0
- coding_agent/ui.py +323 -0
- coding_agent/usage.py +82 -0
- coding_agent/websessions.py +122 -0
- seedagent-0.7.0.dist-info/METADATA +355 -0
- seedagent-0.7.0.dist-info/RECORD +57 -0
- seedagent-0.7.0.dist-info/WHEEL +4 -0
- seedagent-0.7.0.dist-info/entry_points.txt +3 -0
- seedagent-0.7.0.dist-info/licenses/LICENSE +21 -0
coding_agent/__init__.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Personal coding agent: Claude on Azure (Microsoft Foundry) with a manual tool-use loop.
|
|
2
|
+
|
|
3
|
+
Tools:
|
|
4
|
+
- list_directory : list the folders and files in a directory
|
|
5
|
+
- change_directory : move the agent's current directory (never outside the workspace)
|
|
6
|
+
- grep : regex search across files in the workspace
|
|
7
|
+
- read_file : read a file (optionally a line range)
|
|
8
|
+
- edit_file : replace an exact snippet in a file -- shows a diff and asks permission first
|
|
9
|
+
- write_file : create/overwrite a file -- shows a diff and asks permission first
|
|
10
|
+
- download_file : download a URL (http/https) into the workspace -- always asks, even in
|
|
11
|
+
autonomous mode; size-capped (AGENT_DOWNLOAD_MAX_MB, default 50); cloud metadata
|
|
12
|
+
and link-local addresses are refused (they can leak Azure managed-identity tokens)
|
|
13
|
+
- clone_repo : git clone an https or ssh repository into a new folder of the workspace -- always
|
|
14
|
+
asks, even in autonomous mode; shallow by default; hooks and non-network
|
|
15
|
+
protocols (file://, ext::) are disabled
|
|
16
|
+
- screenshot_page : open a page in headless Chromium (your dev server, a local HTML file, or a
|
|
17
|
+
public site) and show Claude the screenshot, plus console errors and failed
|
|
18
|
+
requests -- Claude sees the image itself (text, layout, colors), no OCR needed.
|
|
19
|
+
Needs Playwright: `pip install playwright` (or `uv add --dev playwright`), then
|
|
20
|
+
`playwright install chromium` -- or it uses the installed Edge / Chrome when that
|
|
21
|
+
download is blocked. `coding-agent --check-browser` tests it. localhost / private addresses and workspace files
|
|
22
|
+
open without asking; public sites always ask. AGENT_BROWSER_PATH picks a specific
|
|
23
|
+
Chromium/Chrome/Edge executable.
|
|
24
|
+
- web_open, web_click, web_type, web_back, web_page, web_look, web_close : browse a site step by step in a
|
|
25
|
+
hidden browser that stays open -- the page's text and its links and fields with numbers; the
|
|
26
|
+
user approves each new site and every form that sends data; never passwords or payments
|
|
27
|
+
(same Playwright setup as screenshot_page)
|
|
28
|
+
- view_image : show Claude an image from the workspace (a mockup, a design export, a screenshot)
|
|
29
|
+
- read_pdf : give Claude a PDF's pages as a document it reads itself -- text, tables, layout and
|
|
30
|
+
scanned pages -- or just the extracted text for long documents
|
|
31
|
+
- search_pdf : find a word or phrase in a PDF's text: the page numbers and a short snippet of every match
|
|
32
|
+
- search_library : find the pages of all the PDFs of the library that have a query's words (full-text index, ranked; scans through OCR)
|
|
33
|
+
(the text is extracted once and cached), then open just those pages with read_pdf
|
|
34
|
+
- read_excel : list a workbook's sheets and show cells (values and formulas) of a sheet or range
|
|
35
|
+
- edit_excel : set cell values or formulas in an .xlsx/.xlsm (or create a new workbook) -- shows a
|
|
36
|
+
cell-by-cell diff and asks first; a copy of the previous file is kept in
|
|
37
|
+
$HOME/.coding-agent/backups/; warns (and always asks) when the workbook has charts,
|
|
38
|
+
images or pivot tables, which openpyxl cannot keep (with Excel doing the saving --
|
|
39
|
+
AGENT_EXCEL_BACKEND=xlwings, the default when Excel and xlwings are there -- nothing is lost)
|
|
40
|
+
- view_excel : see a sheet or range as the person sees it (Excel's rendering with charts, or a drawing)
|
|
41
|
+
- format_excel: bold, fills, borders, wrap, widths, number formats, frozen header, filters
|
|
42
|
+
- add_chart, add_table, add_pivot_table : add a chart, an Excel table or a pivot table (pivot
|
|
43
|
+
tables need Excel) -- shows what will be added and asks first; a backup is kept
|
|
44
|
+
- run_python_excel : a Python script run with Excel (xlwings) on a workbook --
|
|
45
|
+
macros, programs and other files refused before it runs; a backup is kept first
|
|
46
|
+
- copy_path : copy a file or a folder inside the workspace -- a text file shows a diff, a
|
|
47
|
+
binary file or folder shows what will be created; asks permission first
|
|
48
|
+
- delete_file : delete a file -- always asks for human validation, even in autonomous mode
|
|
49
|
+
- delete_folder : delete a folder and everything in it -- shows what it contains and always
|
|
50
|
+
asks for human validation, even in autonomous mode; never the workspace root,
|
|
51
|
+
a .git folder, or a folder holding the agent's own files
|
|
52
|
+
(edit_file, write_file, delete_file and delete_folder never touch the agent's own source)
|
|
53
|
+
- ask_human : lets the model ask you a question mid-task
|
|
54
|
+
- git : read-only git -- status, diff and log of the workspace (never commits, checks
|
|
55
|
+
out or changes anything; external diff tools, textconv filters, pagers and
|
|
56
|
+
fsmonitor hooks are disabled so a repository's config cannot run commands)
|
|
57
|
+
- run_python : run a Python snippet, script or module (e.g. pytest) -- asks permission first
|
|
58
|
+
(uses `uv run --frozen/--no-sync` when uv is installed: the project's own
|
|
59
|
+
environment, never rewriting uv.lock; the code cannot start, replace or kill
|
|
60
|
+
processes or modify files, including through ctypes -- see GUARD_SOURCE)
|
|
61
|
+
- load_skill : load a skill's full instructions when a task matches it
|
|
62
|
+
- web_search : Anthropic's server-side web search (runs on Anthropic's side; nothing executes
|
|
63
|
+
locally). AGENT_WEB_SEARCH=20250305 (default; the only version on Foundry
|
|
64
|
+
deployments hosted on Azure), 20260209 (better filtering; Anthropic-hosted
|
|
65
|
+
deployments), or off.
|
|
66
|
+
|
|
67
|
+
Skills are folders with a SKILL.md (a `name` / `description` header, then instructions), found in:
|
|
68
|
+
coding_agent/skills/ -- shipped with the agent
|
|
69
|
+
$HOME/.coding-agent/skills/ -- personal, every project (override with AGENT_SKILLS_DIR)
|
|
70
|
+
<project>/.agent/skills/ -- per project (can be committed)
|
|
71
|
+
A later location overrides an earlier one with the same skill name. Only names and descriptions
|
|
72
|
+
are sent up front; Claude loads a skill's instructions when it needs them.
|
|
73
|
+
|
|
74
|
+
Memory: notes about each project, kept between runs in
|
|
75
|
+
$HOME/.coding-agent/memory/<project>/memories/notes.md (override the root with AGENT_MEMORY_DIR).
|
|
76
|
+
They are given to Claude at the start of each session. Updating them never slows the agent down:
|
|
77
|
+
after each instruction a background thread sends a summary of what happened to a separate
|
|
78
|
+
"memory curator" call, which rewrites notes.md only when something durable was learned. You get
|
|
79
|
+
the next prompt right away; "[memory] ..." shows when the update finishes. AGENT_MEMORY_MODEL
|
|
80
|
+
picks the deployment it uses (default: the main one -- a smaller, cheaper one works well);
|
|
81
|
+
AGENT_MEMORY=off disables updates. On exit the agent waits (up to 60 s) for a pending update.
|
|
82
|
+
|
|
83
|
+
Configuration (environment variables or a .env file):
|
|
84
|
+
ANTHROPIC_FOUNDRY_ENDPOINT https://<resource>.services.ai.azure.com/anthropic
|
|
85
|
+
ANTHROPIC_FOUNDRY_API_KEY API key; leave unset to sign in with a Microsoft work account: the
|
|
86
|
+
Windows session's account, `az login`, ... or else the Microsoft
|
|
87
|
+
sign-in page, once (see coding_agent/signin.py)
|
|
88
|
+
ANTHROPIC_FOUNDRY_DEPLOYMENT your Claude deployment name
|
|
89
|
+
AZURE_TENANT_ID / AZURE_CLIENT_ID tenant and app registration for the sign-in page (optional)
|
|
90
|
+
ANTHROPIC_FOUNDRY_BROWSER_SIGN_IN=0 never open the sign-in page (servers, CI)
|
|
91
|
+
|
|
92
|
+
Or, without Azure: Anthropic's own API (used only when ANTHROPIC_FOUNDRY_ENDPOINT is not set)
|
|
93
|
+
ANTHROPIC_API_KEY an API key from console.anthropic.com
|
|
94
|
+
ANTHROPIC_MODEL a model ID (default claude-sonnet-5)
|
|
95
|
+
CODEAGENT_THINKING off (default), adaptive, between_tools or disabled
|
|
96
|
+
CODEAGENT_EFFORT medium (default), low, high, xhigh, max or default (see example.env)
|
|
97
|
+
|
|
98
|
+
Usage:
|
|
99
|
+
uv sync # once, in the agent's folder (add --extra browser for screenshots)
|
|
100
|
+
uv run coding-agent -d path/to/project "Add input validation to the CLI"
|
|
101
|
+
python agent.py -d path/to/project "..." # same thing, without installing
|
|
102
|
+
coding-agent -d path/to/project "..." -i # keep chatting after the task
|
|
103
|
+
coding-agent -d path/to/project # interactive mode only
|
|
104
|
+
coding-agent -d path/to/project -r # resume the last conversation in this project
|
|
105
|
+
coding-agent -d path/to/project "..." --auto # autonomous mode (see below)
|
|
106
|
+
coding-agent -d path/to/project --where # show where memory and skills are read from
|
|
107
|
+
|
|
108
|
+
As a library (other front ends, e.g. a desktop app), see coding_agent.session:
|
|
109
|
+
from coding_agent import session
|
|
110
|
+
session.open_project(path, ui=MyUI(), tools=[...], system_prompt="...")
|
|
111
|
+
session.send("instruction")
|
|
112
|
+
coding_agent.register_tool(schema, handler) # the application's own tool (see the README)
|
|
113
|
+
The package layout: config (settings), state (the session), ui (the UI interface and the
|
|
114
|
+
terminal), loop (the agent loop), tools/ (one module per tool family), guard (the run_python
|
|
115
|
+
sandbox), skills, memory, context (context window management), cli (the terminal front end).
|
|
116
|
+
(in interactive mode, /skills re-scans the skill folders and shows them)
|
|
117
|
+
|
|
118
|
+
$HOME is used when set; otherwise your user folder (on Windows, %USERPROFILE%).
|
|
119
|
+
|
|
120
|
+
Autonomous mode (--auto, or /auto in interactive mode to toggle, /mode to show): edits,
|
|
121
|
+
new files and run_python are applied without asking (diffs are still printed), and ask_human
|
|
122
|
+
does not wait -- Claude decides and states its assumptions. Deleting a file or a folder always
|
|
123
|
+
waits for your approval, even in autonomous mode. Workspace confinement,
|
|
124
|
+
self-protection and the subprocess block still apply; Ctrl+C stops it. AGENT_MAX_STEPS (default
|
|
125
|
+
100) caps the model calls per instruction in every mode.
|
|
126
|
+
|
|
127
|
+
Token usage: coding_agent.usage adds up what the API reports for every model call (agent, compact, memory) and
|
|
128
|
+
estimates what each tool's results added: mark = usage.snapshot(); session.send(...); usage.since(mark).
|
|
129
|
+
After each instruction the agent shows "[tokens: ... in, ... out]".
|
|
130
|
+
|
|
131
|
+
Logs: everything the agent shows (tool calls and results, changes, warnings, errors, questions, token usage) is also written to
|
|
132
|
+
MEMORY_HOME/<project>/agent.log (about 2 MB at most) through the logger coding_agent.ui. AGENT_LOG_LEVEL=DEBUG adds the model's text;
|
|
133
|
+
AGENT_LOG=off writes no file. File contents and typed answers are never logged.
|
|
134
|
+
|
|
135
|
+
Context management (long sessions): the agent tracks how much of the model's context window
|
|
136
|
+
the conversation uses and prints it after each instruction ("[context] 84k / 200k tokens").
|
|
137
|
+
Past 50% it replaces old tool outputs with a short note (Claude re-reads files when needed);
|
|
138
|
+
past 70% it compacts: a summary call replaces the earlier conversation with a brief (goal,
|
|
139
|
+
decisions, files changed, state, next steps). If the API still says the prompt is too long, it
|
|
140
|
+
compacts and retries once. AGENT_CONTEXT_WINDOW (default 200000) is your deployment's window;
|
|
141
|
+
AGENT_COMPACT_MODEL picks the deployment that writes summaries (default: the main one).
|
|
142
|
+
Pasting: multi-line text pasted at the "You:" prompt (a traceback, a code snippet) is sent as
|
|
143
|
+
one instruction. You can also type three double quotes on a line of their own, then paste or
|
|
144
|
+
type anything, and end with three double quotes on their own line again. Approval prompts ignore anything typed or pasted before they appear, so
|
|
145
|
+
leftover pasted lines can never answer "Apply this change?".
|
|
146
|
+
|
|
147
|
+
Interactive commands: /context shows usage, /compact compacts now, /clear starts a fresh
|
|
148
|
+
conversation (memory notes are kept).
|
|
149
|
+
|
|
150
|
+
`path:line` references in the output are clickable links that open the file at that line.
|
|
151
|
+
Set AGENT_EDITOR to vscode (default), cursor, file, or none.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
from .certificates import use_system_certificates as _use_system_certificates
|
|
155
|
+
|
|
156
|
+
_use_system_certificates() # HTTPS behind a company proxy: trust what the operating system trusts
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def __getattr__(name: str):
|
|
161
|
+
"""configure, active_provider and make_anthropic_client (runtime settings for a host application),
|
|
162
|
+
loaded on first use so that importing the package does not read the configuration."""
|
|
163
|
+
if name in ("configure", "active_provider", "make_anthropic_client"):
|
|
164
|
+
from . import config
|
|
165
|
+
return getattr(config, name)
|
|
166
|
+
if name == "register_tool": # a host application's own tools (loads the tool modules on first use)
|
|
167
|
+
from .tools import register_tool
|
|
168
|
+
return register_tool
|
|
169
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
coding_agent/__main__.py
ADDED
coding_agent/backups.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Previous versions of the workbooks the agent changed, and putting one back.
|
|
2
|
+
|
|
3
|
+
Before each change it saves to a workbook, the agent copies the workbook to
|
|
4
|
+
BACKUP_HOME/<folder>-<code>/<YYYYmmdd-HHMMSS>-<file name>: <folder>-<code> identifies the project
|
|
5
|
+
folder (its name and a hash of its full path), and the time is when the change was saved -- the
|
|
6
|
+
copy is the workbook as it was just before. Backups are kept a few days (cleanup.py).
|
|
7
|
+
|
|
8
|
+
Restoring a version first backs up the current one the same way, so a restore can be undone too.
|
|
9
|
+
Used by the restore_backup tool and by applications (e.g. a "previous versions" list in a window).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import datetime
|
|
15
|
+
import hashlib
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from . import state
|
|
23
|
+
from .config import BACKUP_HOME
|
|
24
|
+
|
|
25
|
+
NAME = re.compile(r"^(\d{8})-(\d{6})-(.+)$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def folder(workspace: Path | None = None) -> Path:
|
|
29
|
+
"""The backups of a project folder (default: the current workspace)."""
|
|
30
|
+
ws = workspace or state.workspace
|
|
31
|
+
return BACKUP_HOME / f"{ws.name}-{hashlib.sha256(str(ws).encode()).hexdigest()[:8]}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def save(path: Path, content: bytes, workspace: Path | None = None) -> Path:
|
|
35
|
+
"""Keep `content` (a file's current bytes) as a previous version of `path`. Returns the copy."""
|
|
36
|
+
target = folder(workspace)
|
|
37
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
backup = target / f"{time.strftime('%Y%m%d-%H%M%S')}-{path.name}"
|
|
39
|
+
while backup.exists(): # two saves within the same second
|
|
40
|
+
backup = backup.with_name(f"{time.strftime('%Y%m%d-%H%M%S', time.localtime(time.time() + 1))}-{path.name}")
|
|
41
|
+
time.sleep(0.01)
|
|
42
|
+
backup.write_bytes(content)
|
|
43
|
+
return backup
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def moment(version: str) -> datetime.datetime:
|
|
47
|
+
match = NAME.match(version)
|
|
48
|
+
if not match:
|
|
49
|
+
raise ValueError(f"not a backup name: {version}")
|
|
50
|
+
return datetime.datetime.strptime(match.group(1) + match.group(2), "%Y%m%d%H%M%S")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def versions(name: str, workspace: Path | None = None) -> list[dict]:
|
|
54
|
+
"""The previous versions of the file called `name` in a project folder, newest first: id (the
|
|
55
|
+
backup's file name), time (ISO, local time of the change it preceded) and size in bytes."""
|
|
56
|
+
found = []
|
|
57
|
+
backups = folder(workspace)
|
|
58
|
+
for file in backups.iterdir() if backups.is_dir() else []:
|
|
59
|
+
match = NAME.match(file.name)
|
|
60
|
+
if not (file.is_file() and match and match.group(3) == name):
|
|
61
|
+
continue
|
|
62
|
+
try:
|
|
63
|
+
when = moment(file.name)
|
|
64
|
+
except ValueError:
|
|
65
|
+
continue
|
|
66
|
+
found.append({"id": file.name, "time": when.isoformat(timespec="seconds"), "size": file.stat().st_size})
|
|
67
|
+
return sorted(found, key=lambda v: v["id"], reverse=True)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def restore(path: Path, version: str, workspace: Path | None = None) -> str:
|
|
71
|
+
"""Put a previous version back in place of `path`, after backing up the current one. Returns a
|
|
72
|
+
sentence for the person. Raises ValueError (no such version) or PermissionError (the file is
|
|
73
|
+
open, e.g. in Excel)."""
|
|
74
|
+
if version not in {v["id"] for v in versions(path.name, workspace)}:
|
|
75
|
+
raise ValueError(f"No previous version {version} of {path.name}.")
|
|
76
|
+
source = folder(workspace) / version
|
|
77
|
+
if path.exists(): # so that the restore can be undone
|
|
78
|
+
save(path, path.read_bytes(), workspace)
|
|
79
|
+
tmp = path.with_name(f".{path.name}.restore-tmp")
|
|
80
|
+
try:
|
|
81
|
+
shutil.copyfile(source, tmp)
|
|
82
|
+
os.replace(tmp, path)
|
|
83
|
+
except PermissionError:
|
|
84
|
+
raise PermissionError(f"{path.name} could not be replaced: it is open (in Excel?). Close it and try again.")
|
|
85
|
+
finally:
|
|
86
|
+
if tmp.exists():
|
|
87
|
+
tmp.unlink()
|
|
88
|
+
return (f"{path.name} is back to its version from before the change of {moment(version):%d %b %Y at %H:%M}. "
|
|
89
|
+
"The version it replaced is kept among the previous versions.")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""HTTPS through a company proxy: trust the certificates the operating system trusts.
|
|
2
|
+
|
|
3
|
+
Many company networks inspect HTTPS: a proxy re-signs every site with the company's own root
|
|
4
|
+
certificate, installed in the Windows (or macOS) certificate store -- which is why the browser works.
|
|
5
|
+
Python brings its own list of certificates (certifi) and does not know that root, so every HTTPS call
|
|
6
|
+
fails with CERTIFICATE_VERIFY_FAILED. (uv's --system-certs / UV_SYSTEM_CERTS only covers uv's own
|
|
7
|
+
downloads: the packages it installs, not the program it runs.)
|
|
8
|
+
|
|
9
|
+
With truststore, Python checks certificates with the operating system instead, like the browser:
|
|
10
|
+
every HTTPS connection of the agent -- Claude, download_file, the gateway -- trusts what Windows
|
|
11
|
+
trusts. It is turned on when the package is imported; AGENT_SYSTEM_CERTS=0 turns it off.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import ssl
|
|
18
|
+
|
|
19
|
+
_enabled = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def use_system_certificates() -> bool:
|
|
23
|
+
"""Make Python's ssl use the operating system's certificate store (once). True if it is on."""
|
|
24
|
+
global _enabled
|
|
25
|
+
if _enabled:
|
|
26
|
+
return True
|
|
27
|
+
if (os.environ.get("AGENT_SYSTEM_CERTS") or "1").strip().lower() in ("0", "false", "no", "off"):
|
|
28
|
+
return False
|
|
29
|
+
try:
|
|
30
|
+
import truststore
|
|
31
|
+
except ImportError: # not installed: Python's own certificates, as before
|
|
32
|
+
return False
|
|
33
|
+
truststore.inject_into_ssl()
|
|
34
|
+
_enabled = True
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def ssl_context() -> ssl.SSLContext:
|
|
39
|
+
"""A client context that checks certificates with the operating system when it can."""
|
|
40
|
+
try:
|
|
41
|
+
import truststore
|
|
42
|
+
return truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
43
|
+
except ImportError:
|
|
44
|
+
return ssl.create_default_context()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def explain(error: BaseException) -> str | None:
|
|
48
|
+
"""A plain explanation for a certificate failure, None for any other error."""
|
|
49
|
+
if "CERTIFICATE_VERIFY_FAILED" not in str(error) and "certificate verify failed" not in str(error).lower():
|
|
50
|
+
return None
|
|
51
|
+
return ("the site's HTTPS certificate is not trusted on this machine. On a company network this "
|
|
52
|
+
"usually means a proxy inspects HTTPS with the company's own certificate: this is not a "
|
|
53
|
+
"temporary error, do not retry. The agent trusts the operating system's certificates when "
|
|
54
|
+
"truststore is installed (uv sync) and AGENT_SYSTEM_CERTS is not 0; otherwise ask IT for the "
|
|
55
|
+
"company root certificate and set SSL_CERT_FILE to it.")
|
coding_agent/cleanup.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Clean-up of what the agent keeps on the machine, so nothing grows forever.
|
|
2
|
+
|
|
3
|
+
Runs by itself, once per process: at the start of the coding-agent command, and the first time an
|
|
4
|
+
application opens a project (session.open_project). AGENT_CLEANUP=off switches this automatic run
|
|
5
|
+
off; an application that prefers to decide when (a server running for weeks might call it daily)
|
|
6
|
+
calls cleanup.run() itself.
|
|
7
|
+
|
|
8
|
+
- Backups (BACKUP_HOME, ~/.coding-agent/backups): the copy of a workbook taken before each change
|
|
9
|
+
is deleted after AGENT_BACKUP_DAYS days (default 3): long enough to undo a change noticed when
|
|
10
|
+
the workbook is checked, without keeping copies of confidential workbooks on the machine.
|
|
11
|
+
- Saved conversations (MEMORY_HOME/<project>/conversation.json, used by --resume): deleted after
|
|
12
|
+
AGENT_CONVERSATION_DAYS days (default 30).
|
|
13
|
+
- Memory (MEMORY_HOME/<project>/): the whole memory of a project folder not used for
|
|
14
|
+
AGENT_MEMORY_DAYS days (default 90) is deleted. The notes of a project in use are kept; the
|
|
15
|
+
memory curator already keeps them under a size limit.
|
|
16
|
+
Nothing here ever touches the projects themselves. A file that cannot be deleted (open elsewhere,
|
|
17
|
+
no permission) is left for next time.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
BACKUP_DAYS = 3
|
|
28
|
+
CONVERSATION_DAYS = 30
|
|
29
|
+
MEMORY_DAYS = 90
|
|
30
|
+
DAY = 86_400
|
|
31
|
+
_ran = False # the clean-up has run in this process (by run(), automatically or by the application)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def days(variable: str, default: int) -> int:
|
|
35
|
+
"""A number of days from the environment (at least 1), or the default."""
|
|
36
|
+
try:
|
|
37
|
+
return max(1, int(os.environ.get(variable) or default))
|
|
38
|
+
except ValueError:
|
|
39
|
+
return default
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def older_than(path: Path, days_old: float, now: float) -> bool:
|
|
43
|
+
return now - path.stat().st_mtime > days_old * DAY
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def clean_backups(backups: Path, days_old: float, now: float) -> int:
|
|
47
|
+
"""Backups older than `days_old`. Returns how many were deleted."""
|
|
48
|
+
if not backups.is_dir():
|
|
49
|
+
return 0
|
|
50
|
+
deleted = 0
|
|
51
|
+
for file in [f for f in backups.rglob("*") if f.is_file()]:
|
|
52
|
+
if older_than(file, days_old, now):
|
|
53
|
+
try:
|
|
54
|
+
file.unlink()
|
|
55
|
+
deleted += 1
|
|
56
|
+
except OSError:
|
|
57
|
+
pass
|
|
58
|
+
for folder in sorted((p for p in backups.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True):
|
|
59
|
+
if not any(folder.iterdir()):
|
|
60
|
+
folder.rmdir()
|
|
61
|
+
return deleted
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def clean_memory(memory: Path, days_unused: float, now: float) -> int:
|
|
65
|
+
"""The whole memory of each project not used for `days_unused` days (its newest file is older).
|
|
66
|
+
Returns how many projects' memories were deleted."""
|
|
67
|
+
if not memory.is_dir():
|
|
68
|
+
return 0
|
|
69
|
+
deleted = 0
|
|
70
|
+
for project in memory.iterdir():
|
|
71
|
+
if not project.is_dir():
|
|
72
|
+
continue
|
|
73
|
+
newest = max((f.stat().st_mtime for f in project.rglob("*") if f.is_file()), default=project.stat().st_mtime)
|
|
74
|
+
if now - newest > days_unused * DAY:
|
|
75
|
+
shutil.rmtree(project, ignore_errors=True)
|
|
76
|
+
deleted += not project.exists()
|
|
77
|
+
return deleted
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def clean_conversations(memory: Path, days_old: float, now: float) -> int:
|
|
81
|
+
"""Saved conversations older than `days_old`. Returns how many were deleted."""
|
|
82
|
+
deleted = 0
|
|
83
|
+
for file in memory.glob("*/conversation.json") if memory.is_dir() else []:
|
|
84
|
+
if older_than(file, days_old, now):
|
|
85
|
+
try:
|
|
86
|
+
file.unlink()
|
|
87
|
+
deleted += 1
|
|
88
|
+
except OSError:
|
|
89
|
+
pass
|
|
90
|
+
return deleted
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def enabled() -> bool:
|
|
94
|
+
"""AGENT_CLEANUP=off (or 0, false, no) switches the automatic clean-up off."""
|
|
95
|
+
return (os.environ.get("AGENT_CLEANUP") or "on").strip().lower() not in ("off", "0", "false", "no")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def run_once(backups: Path | None = None, memory: Path | None = None) -> str | None:
|
|
99
|
+
"""The clean-up, unless it already ran in this process or AGENT_CLEANUP=off: the summary, or None when it did not run."""
|
|
100
|
+
if _ran or not enabled():
|
|
101
|
+
return None
|
|
102
|
+
return run(backups, memory)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def run(backups: Path | None = None, memory: Path | None = None, now: float | None = None) -> str:
|
|
106
|
+
"""The whole clean-up; a one-line summary. Never raises. Given both folders, the agent's settings
|
|
107
|
+
are not loaded (an application may configure them later, before its first call to Claude)."""
|
|
108
|
+
global _ran
|
|
109
|
+
_ran = True
|
|
110
|
+
if backups is None or memory is None:
|
|
111
|
+
from .config import BACKUP_HOME, MEMORY_HOME
|
|
112
|
+
|
|
113
|
+
backups = BACKUP_HOME if backups is None else backups
|
|
114
|
+
memory = MEMORY_HOME if memory is None else memory
|
|
115
|
+
now = time.time() if now is None else now
|
|
116
|
+
done = []
|
|
117
|
+
for label, step in [
|
|
118
|
+
("old backup(s)", lambda: clean_backups(backups, days("AGENT_BACKUP_DAYS", BACKUP_DAYS), now)),
|
|
119
|
+
("unused project memories", lambda: clean_memory(memory, days("AGENT_MEMORY_DAYS", MEMORY_DAYS), now)),
|
|
120
|
+
("old saved conversation(s)", lambda: clean_conversations(memory, days("AGENT_CONVERSATION_DAYS",
|
|
121
|
+
CONVERSATION_DAYS), now)),
|
|
122
|
+
]:
|
|
123
|
+
try:
|
|
124
|
+
if count := step():
|
|
125
|
+
done.append(f"{count} {label}")
|
|
126
|
+
except OSError as e:
|
|
127
|
+
done.append(f"{label}: not cleaned ({e})")
|
|
128
|
+
return "Clean-up: " + (", ".join(done) if done else "nothing to delete")
|
coding_agent/cli.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""The terminal front end: `coding-agent -d <project> [instruction]`."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import anthropic
|
|
8
|
+
|
|
9
|
+
from . import history, session, state
|
|
10
|
+
from .config import CLEAR_AT, COMPACT_AT, UV, WEB_SEARCH, _get_client, provider_label, uses_deepseek
|
|
11
|
+
from .errors import connection_summary
|
|
12
|
+
from .context import compact_between_instructions, context_status, reset_usage
|
|
13
|
+
from .conversation import save_conversation
|
|
14
|
+
from .loop import send, set_auto_mode
|
|
15
|
+
from .memory import print_memory_status
|
|
16
|
+
from .skills import discover_skills, locations_report, skills_catalog
|
|
17
|
+
from .tools.browser import check_browser
|
|
18
|
+
from .ui import TerminalUI, read_text
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_args() -> argparse.Namespace:
|
|
22
|
+
parser = argparse.ArgumentParser(description="Personal coding agent (Claude on Azure).")
|
|
23
|
+
parser.add_argument("instruction", nargs="?", help="Task for the agent. Omit to start in interactive mode.")
|
|
24
|
+
parser.add_argument("-d", "--dir", default=".", help="Project directory the agent works in (default: current directory).")
|
|
25
|
+
parser.add_argument("-i", "--interactive", action="store_true", help="Keep chatting after the instruction finishes.")
|
|
26
|
+
parser.add_argument("-r", "--resume", action="store_true", help="Continue the last conversation in this project.")
|
|
27
|
+
parser.add_argument("--auto", action="store_true", help="Autonomous mode: apply edits and Python runs without asking.")
|
|
28
|
+
parser.add_argument("--read", action="append", default=[], metavar="DIR",
|
|
29
|
+
help="Another folder the agent may read but never write (repeat for several).")
|
|
30
|
+
parser.add_argument("--where", action="store_true", help="Show where memory and skills are read from, then exit.")
|
|
31
|
+
parser.add_argument("--check", action="store_true", help="Test each step of a call to Claude (sign-in, request, streaming, thinking, tools), then exit.")
|
|
32
|
+
parser.add_argument("--memories", nargs="?", const="", metavar="NAME",
|
|
33
|
+
help="List every project's memory (or show one project's notes), then exit.")
|
|
34
|
+
parser.add_argument("--forget", metavar="NAME",
|
|
35
|
+
help="Delete a project's memory (NAME, 'all', or '.' for the -d project), after confirmation, then exit.")
|
|
36
|
+
parser.add_argument("--forget-logins", nargs="?", const="all", metavar="SITE",
|
|
37
|
+
help="Delete the sign-ins kept for the browsing tools (a site, or all), then exit.")
|
|
38
|
+
parser.add_argument("--forget-secrets", action="store_true", help="Delete the mail and SMS logins saved in ~/.coding-agent/secrets, then exit.")
|
|
39
|
+
parser.add_argument("--ocr", nargs="+", metavar="PDF", help="Read the scanned pages of these PDFs once with OCR (tesseract), keep the text for search_pdf and read_pdf, then exit.")
|
|
40
|
+
parser.add_argument("--index", nargs="+", metavar="FOLDER", help="Index the PDFs of these folders for search_library (scanned pages are read with OCR, which takes a while), then exit.")
|
|
41
|
+
parser.add_argument("--check-browser", action="store_true", help="Test the browser used by screenshot_page, then exit.")
|
|
42
|
+
return parser.parse_args()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run_ocr(files: list[str]) -> None:
|
|
46
|
+
"""`coding-agent --ocr FILE...`: OCR every page without a text layer, with a progress line, and keep the text."""
|
|
47
|
+
from . import ocr
|
|
48
|
+
from .config import OCR_LANGS
|
|
49
|
+
from .tools.documents import pdf_page_texts
|
|
50
|
+
if not ocr.available():
|
|
51
|
+
raise SystemExit(ocr.missing_message())
|
|
52
|
+
for name in files:
|
|
53
|
+
path = Path(name).expanduser().resolve()
|
|
54
|
+
texts = pdf_page_texts(path)
|
|
55
|
+
blank = [n for n, t in enumerate(texts, 1) if not t]
|
|
56
|
+
done = ocr.cached(path)
|
|
57
|
+
todo = [n for n in blank if n not in done]
|
|
58
|
+
print(f"{path.name}: {len(texts)} page(s), {len(blank)} without a text layer, {len(blank) - len(todo)} already read ({OCR_LANGS})")
|
|
59
|
+
if todo:
|
|
60
|
+
ocr.read_pages(path, todo, progress=lambda i, total: print(f"\r OCR {i}/{total}", end="", flush=True))
|
|
61
|
+
print()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run_index(folders: list[str]) -> None:
|
|
65
|
+
"""`coding-agent --index FOLDER...`: (re)index the PDFs of each folder, OCR included; only new or changed files are read."""
|
|
66
|
+
from . import index, ocr
|
|
67
|
+
if not ocr.available():
|
|
68
|
+
print("Warning: " + ocr.missing_message() + " Scanned pages will not be indexed.")
|
|
69
|
+
for name in folders:
|
|
70
|
+
root = Path(name).expanduser().resolve()
|
|
71
|
+
if not root.is_dir():
|
|
72
|
+
raise SystemExit(f"Not a folder: {root}")
|
|
73
|
+
read = index.refresh(root, ocr_scans=ocr.available(), log=print,
|
|
74
|
+
progress=lambda i, total: print(f"\r OCR {i}/{total}", end="", flush=True))
|
|
75
|
+
print(f"{root}: {len(index.documents_under(root))} document(s), {len(read)} read")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main() -> None:
|
|
79
|
+
args = parse_args()
|
|
80
|
+
if args.memories is not None or args.forget: # developers: no project opened, no call to Claude
|
|
81
|
+
from . import memories
|
|
82
|
+
from .config import MEMORY_HOME
|
|
83
|
+
current = session.project_id(Path(args.dir).expanduser().resolve())
|
|
84
|
+
if args.forget:
|
|
85
|
+
print(memories.forget(MEMORY_HOME, current if args.forget == "." else args.forget))
|
|
86
|
+
elif args.memories:
|
|
87
|
+
print(memories.show(MEMORY_HOME, current if args.memories == "." else args.memories))
|
|
88
|
+
else:
|
|
89
|
+
print(memories.listing(MEMORY_HOME, current))
|
|
90
|
+
return
|
|
91
|
+
if args.forget_logins:
|
|
92
|
+
from . import websessions
|
|
93
|
+
removed = websessions.forget(args.forget_logins)
|
|
94
|
+
print("Removed: " + ", ".join(removed) if removed else "No saved sign-in" + ("" if args.forget_logins == "all" else f" for {args.forget_logins}") + ".")
|
|
95
|
+
return
|
|
96
|
+
if args.forget_secrets:
|
|
97
|
+
from . import credentials
|
|
98
|
+
removed = credentials.forget()
|
|
99
|
+
print("Removed: " + ", ".join(removed) if removed else "No saved login.")
|
|
100
|
+
return
|
|
101
|
+
if args.index:
|
|
102
|
+
return run_index(args.index)
|
|
103
|
+
if args.ocr:
|
|
104
|
+
return run_ocr(args.ocr)
|
|
105
|
+
if args.check_browser:
|
|
106
|
+
print(check_browser())
|
|
107
|
+
return
|
|
108
|
+
try:
|
|
109
|
+
workspace = Path(args.dir).expanduser().resolve()
|
|
110
|
+
if args.check:
|
|
111
|
+
from .diagnose import run_check
|
|
112
|
+
session.open_project(workspace, ui=TerminalUI())
|
|
113
|
+
raise SystemExit(0 if run_check() else 1)
|
|
114
|
+
if args.where: # no API client, no saved conversation needed
|
|
115
|
+
session.open_project(workspace, ui=TerminalUI())
|
|
116
|
+
print(f"Workspace: {state.workspace}")
|
|
117
|
+
print(locations_report(verbose=True))
|
|
118
|
+
return
|
|
119
|
+
client = _get_client()
|
|
120
|
+
from .cleanup import run_once as clean_up
|
|
121
|
+
if (cleaned := clean_up()) and cleaned != "Clean-up: nothing to delete": # old backups, unused memories
|
|
122
|
+
print(cleaned)
|
|
123
|
+
print(f"Workspace: {workspace}")
|
|
124
|
+
print(f"Python runner: {'uv run (' + UV + ')' if UV else sys.executable + ' (uv not found)'}")
|
|
125
|
+
session.open_project(workspace, ui=TerminalUI(), resume=args.resume, auto=args.auto)
|
|
126
|
+
state.ask_read_outside = True # reading outside the project: asks first, once per folder
|
|
127
|
+
for folder in args.read: # read-only folders, e.g. documents kept elsewhere
|
|
128
|
+
print(f"Read-only: {session.add_read_folder(folder)}")
|
|
129
|
+
except NotADirectoryError as e:
|
|
130
|
+
raise SystemExit(str(e))
|
|
131
|
+
print(f"{provider_label()}: {connection_summary()}")
|
|
132
|
+
print(locations_report(verbose=False))
|
|
133
|
+
print(f"Web search: {'off' if WEB_SEARCH == 'off' or uses_deepseek() else 'web_search_' + WEB_SEARCH}")
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
interact(client, session.messages, args)
|
|
137
|
+
finally:
|
|
138
|
+
session.close()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def interact(client: anthropic.Anthropic, messages: list, args: argparse.Namespace) -> None:
|
|
142
|
+
if args.instruction:
|
|
143
|
+
ok = send(client, messages, args.instruction)
|
|
144
|
+
if not args.interactive:
|
|
145
|
+
if not ok:
|
|
146
|
+
raise SystemExit(1) # main() still waits for the memory update first
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
print("Interactive mode. Type 'exit' to quit. Multi-line pastes are sent as one message (or wrap "
|
|
150
|
+
"text in \"\"\" lines).\nCommands: /auto (toggle autonomous mode), /mode, /skills (re-scan "
|
|
151
|
+
"skills), /context, /compact, /clear.")
|
|
152
|
+
history.enable() # up arrow recalls earlier questions
|
|
153
|
+
while True:
|
|
154
|
+
print_memory_status()
|
|
155
|
+
try:
|
|
156
|
+
print()
|
|
157
|
+
user_input = read_text(history.prompt("\033[1mYou:\033[0m ")).strip()
|
|
158
|
+
except (EOFError, KeyboardInterrupt):
|
|
159
|
+
break
|
|
160
|
+
history.remember(user_input)
|
|
161
|
+
if user_input.lower() in ("exit", "quit"):
|
|
162
|
+
break
|
|
163
|
+
if user_input.lower() == "/auto":
|
|
164
|
+
set_auto_mode(not state.auto_mode)
|
|
165
|
+
continue
|
|
166
|
+
if user_input.lower() == "/mode":
|
|
167
|
+
print(f"Autonomous mode is {'ON' if state.auto_mode else 'OFF'}.")
|
|
168
|
+
continue
|
|
169
|
+
if user_input.lower() == "/context":
|
|
170
|
+
print(f"Context: {context_status(messages)} in {len(messages)} messages "
|
|
171
|
+
f"(window from AGENT_CONTEXT_WINDOW; tool outputs cleared past {CLEAR_AT:.0%}, "
|
|
172
|
+
f"compaction past {COMPACT_AT:.0%}).")
|
|
173
|
+
print(f"This session: {state.context['cleared']} tool output(s) cleared, "
|
|
174
|
+
f"{state.context['compactions']} compaction(s).")
|
|
175
|
+
continue
|
|
176
|
+
if user_input.lower() == "/compact":
|
|
177
|
+
try:
|
|
178
|
+
compact_between_instructions(client, messages)
|
|
179
|
+
except (anthropic.APIError, RuntimeError) as e:
|
|
180
|
+
print(f"[compaction failed] {e}")
|
|
181
|
+
continue
|
|
182
|
+
if user_input.lower() == "/clear":
|
|
183
|
+
messages.clear()
|
|
184
|
+
state.pending_blocks.clear()
|
|
185
|
+
state.memory_sent = False
|
|
186
|
+
state.pdf_read_as_text = False # the conversation is empty: read_pdf's visual mode needs a text read again
|
|
187
|
+
reset_usage()
|
|
188
|
+
save_conversation(messages)
|
|
189
|
+
print("Started a fresh conversation (memory notes are kept).")
|
|
190
|
+
continue
|
|
191
|
+
if user_input.lower() == "/skills":
|
|
192
|
+
state.skills = discover_skills()
|
|
193
|
+
print(locations_report(verbose=True))
|
|
194
|
+
state.skills_note = "Updated skill list (the user re-scanned the skill folders):\n" + skills_catalog()
|
|
195
|
+
continue
|
|
196
|
+
if user_input:
|
|
197
|
+
send(client, messages, user_input)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
if __name__ == "__main__":
|
|
201
|
+
main()
|