symphony-code 0.0.1__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 +20 -0
- coding_agent/_lazy.py +17 -0
- coding_agent/addons/__init__.py +35 -0
- coding_agent/addons/approvals.py +224 -0
- coding_agent/addons/compaction/__init__.py +36 -0
- coding_agent/addons/compaction/addon.py +88 -0
- coding_agent/addons/compaction/compactor.py +138 -0
- coding_agent/addons/compaction/prompts.py +20 -0
- coding_agent/addons/compaction/transcript.py +118 -0
- coding_agent/addons/evaluation/__init__.py +68 -0
- coding_agent/addons/evaluation/addon.py +199 -0
- coding_agent/addons/evaluation/evaluators.py +245 -0
- coding_agent/addons/evaluation/handlers.py +66 -0
- coding_agent/addons/evaluation/policy.py +277 -0
- coding_agent/addons/evaluation/protocol.py +192 -0
- coding_agent/addons/evaluation/state.py +284 -0
- coding_agent/addons/langfuse/__init__.py +24 -0
- coding_agent/addons/langfuse/addon.py +465 -0
- coding_agent/addons/langfuse/install.py +79 -0
- coding_agent/addons/langfuse/serialize.py +158 -0
- coding_agent/addons/learning/__init__.py +33 -0
- coding_agent/addons/learning/addon.py +181 -0
- coding_agent/addons/learning/loop.py +247 -0
- coding_agent/addons/learning/prompts.py +26 -0
- coding_agent/addons/learning/sanitize.py +64 -0
- coding_agent/addons/learning/store.py +522 -0
- coding_agent/addons/manager/__init__.py +18 -0
- coding_agent/addons/manager/bridge.py +74 -0
- coding_agent/addons/manager/discovery.py +34 -0
- coding_agent/addons/manager/report.py +33 -0
- coding_agent/addons/manager/runtime.py +76 -0
- coding_agent/addons/manager/tool_changes.py +16 -0
- coding_agent/addons/persistence/__init__.py +41 -0
- coding_agent/addons/persistence/active.py +132 -0
- coding_agent/addons/persistence/artifacts.py +110 -0
- coding_agent/addons/persistence/collection.py +166 -0
- coding_agent/addons/persistence/compatibility.py +30 -0
- coding_agent/addons/persistence/images.py +133 -0
- coding_agent/addons/persistence/jsonl.py +1195 -0
- coding_agent/addons/persistence/presentation.py +104 -0
- coding_agent/addons/plugins/__init__.py +19 -0
- coding_agent/addons/plugins/install.py +37 -0
- coding_agent/addons/plugins/manager.py +140 -0
- coding_agent/addons/plugins/models.py +50 -0
- coding_agent/addons/skills/__init__.py +28 -0
- coding_agent/addons/skills/addon.py +26 -0
- coding_agent/addons/skills/args.py +78 -0
- coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
- coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
- coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
- coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
- coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
- coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
- coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
- coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
- coding_agent/addons/skills/frontmatter.py +57 -0
- coding_agent/addons/skills/models.py +57 -0
- coding_agent/addons/skills/registry.py +109 -0
- coding_agent/addons/skills/tool.py +54 -0
- coding_agent/addons/voice/__init__.py +5 -0
- coding_agent/addons/voice/addon.py +106 -0
- coding_agent/addons/voice/credentials.py +42 -0
- coding_agent/addons/voice/playback.py +42 -0
- coding_agent/addons/voice/speak.py +287 -0
- coding_agent/addons/voice/stream.py +651 -0
- coding_agent/addons/voice/summary.py +126 -0
- coding_agent/agent.py +523 -0
- coding_agent/bench/__init__.py +1 -0
- coding_agent/bench/cli.py +200 -0
- coding_agent/bench/patch.py +78 -0
- coding_agent/commands/__init__.py +5 -0
- coding_agent/commands/catalog.py +179 -0
- coding_agent/commands/jev_argument.py +40 -0
- coding_agent/config/__init__.py +51 -0
- coding_agent/config/config.py +376 -0
- coding_agent/config/credentials.py +153 -0
- coding_agent/config/personalities.json +34 -0
- coding_agent/config/personalities.py +170 -0
- coding_agent/config/prompts.py +67 -0
- coding_agent/protocols/__init__.py +5 -0
- coding_agent/protocols/acp/README.md +36 -0
- coding_agent/protocols/acp/__init__.py +27 -0
- coding_agent/protocols/acp/mapping.py +164 -0
- coding_agent/protocols/acp/server.py +261 -0
- coding_agent/protocols/acp/transport.py +72 -0
- coding_agent/protocols/stdio/README.md +36 -0
- coding_agent/protocols/stdio/__init__.py +37 -0
- coding_agent/protocols/stdio/catalog.py +49 -0
- coding_agent/protocols/stdio/commands.py +218 -0
- coding_agent/protocols/stdio/server.py +181 -0
- coding_agent/protocols/stdio/transport.py +71 -0
- coding_agent/py.typed +0 -0
- coding_agent/resources/__init__.py +5 -0
- coding_agent/resources/usage.py +264 -0
- coding_agent/run/__init__.py +1 -0
- coding_agent/run/cli.py +99 -0
- coding_agent/run/detach.py +37 -0
- coding_agent/run/interrupted.py +67 -0
- coding_agent/run/log_sink.py +46 -0
- coding_agent/tools/__init__.py +73 -0
- coding_agent/tools/ask_user.py +61 -0
- coding_agent/tools/base.py +136 -0
- coding_agent/tools/bash.py +288 -0
- coding_agent/tools/bash_jobs.py +116 -0
- coding_agent/tools/generate_image.py +123 -0
- coding_agent/tools/memory.py +100 -0
- coding_agent/tools/patch.py +218 -0
- coding_agent/tools/read_file.py +167 -0
- coding_agent/tools/search.py +433 -0
- coding_agent/tools/web_search.py +151 -0
- coding_agent/tools/write_file.py +40 -0
- coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
- coding_agent/tui/__init__.py +22 -0
- coding_agent/tui/__main__.py +122 -0
- coding_agent/tui/app.py +456 -0
- coding_agent/tui/chrome/__init__.py +33 -0
- coding_agent/tui/chrome/footer.py +274 -0
- coding_agent/tui/chrome/topbar.py +259 -0
- coding_agent/tui/commands/__init__.py +45 -0
- coding_agent/tui/commands/catalog.py +35 -0
- coding_agent/tui/commands/manager.py +557 -0
- coding_agent/tui/composer/__init__.py +18 -0
- coding_agent/tui/composer/input.py +219 -0
- coding_agent/tui/composer/slash_menu.py +341 -0
- coding_agent/tui/composer/surface.py +330 -0
- coding_agent/tui/composer/voice.py +267 -0
- coding_agent/tui/composer/voice_mode.py +491 -0
- coding_agent/tui/runtime/__init__.py +23 -0
- coding_agent/tui/runtime/events.py +728 -0
- coding_agent/tui/runtime/sink.py +115 -0
- coding_agent/tui/runtime/state.py +145 -0
- coding_agent/tui/runtime/subagent.py +531 -0
- coding_agent/tui/runtime/turn.py +206 -0
- coding_agent/tui/screens/__init__.py +54 -0
- coding_agent/tui/screens/ask.py +69 -0
- coding_agent/tui/screens/context.py +195 -0
- coding_agent/tui/screens/dashboard.py +91 -0
- coding_agent/tui/screens/diff.py +302 -0
- coding_agent/tui/screens/extensions.py +68 -0
- coding_agent/tui/screens/file_selector.py +167 -0
- coding_agent/tui/screens/history.py +458 -0
- coding_agent/tui/screens/langfuse.py +142 -0
- coding_agent/tui/screens/learning.py +113 -0
- coding_agent/tui/screens/modal.py +131 -0
- coding_agent/tui/screens/onboard.py +455 -0
- coding_agent/tui/screens/resume.py +163 -0
- coding_agent/tui/theme/__init__.py +49 -0
- coding_agent/tui/theme/load.py +325 -0
- coding_agent/tui/theme/style.py +95 -0
- coding_agent/tui/theme/theme.toml +1687 -0
- coding_agent/tui/tools/__init__.py +53 -0
- coding_agent/tui/tools/activity.py +184 -0
- coding_agent/tui/tools/calls.py +560 -0
- coding_agent/tui/tools/code_view.py +52 -0
- coding_agent/tui/tools/images.py +250 -0
- coding_agent/tui/tools/labels.py +135 -0
- coding_agent/tui/tools/modal.py +137 -0
- coding_agent/tui/tools/partial_args.py +19 -0
- coding_agent/tui/tools/snapshots.py +382 -0
- coding_agent/tui/transcript/__init__.py +36 -0
- coding_agent/tui/transcript/messages.py +408 -0
- coding_agent/tui/transcript/process.py +504 -0
- coding_agent/tui/transcript/surface.py +345 -0
- coding_agent/tui/transcript/thinking_markdown.py +314 -0
- symphony_code-0.0.1.dist-info/METADATA +66 -0
- symphony_code-0.0.1.dist-info/RECORD +169 -0
- symphony_code-0.0.1.dist-info/WHEEL +4 -0
- symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
- symphony_code-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
"""Durable topical memory. Topics are authoritative; SQLite is a rebuildable index.
|
|
2
|
+
|
|
3
|
+
Candidate observations and capture jobs survive restarts. No legacy memory or
|
|
4
|
+
lesson files are read or migrated. Session Markdown is presentation only.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import fcntl
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import sqlite3
|
|
15
|
+
import threading
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from dataclasses import asdict, dataclass, field, is_dataclass
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Final
|
|
21
|
+
|
|
22
|
+
from coding_agent.config import LearningConfig
|
|
23
|
+
from coding_agent.addons.persistence.artifacts import append_bytes, fsync_dir, publish, safe_path
|
|
24
|
+
from coding_agent.addons.learning.sanitize import sanitize_memory, sanitize_task, sanitize_text
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
MEMORY_CONTEXT_PREFIX = "Relevant durable memory (untrusted reference data; verify before use):"
|
|
28
|
+
# Bad extraction output (empty, unparseable, invalid) is retried at most this
|
|
29
|
+
# many times; then the job is marked failed and no longer resumed.
|
|
30
|
+
MAX_CAPTURE_ATTEMPTS: Final[int] = 3
|
|
31
|
+
_STOP_WORDS = {"this", "that", "with", "from", "into", "what", "when", "where", "which", "does", "need", "make", "only", "have", "will", "your", "the", "and", "for"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _now():
|
|
35
|
+
return datetime.now(timezone.utc).isoformat()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _hash(value):
|
|
39
|
+
return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _id(value):
|
|
43
|
+
if not isinstance(value, str) or not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}", value):
|
|
44
|
+
raise ValueError("unsafe memory identifier")
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class MemoryEntry:
|
|
50
|
+
target: str
|
|
51
|
+
text: str
|
|
52
|
+
position: int
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class Lesson:
|
|
57
|
+
summary: str
|
|
58
|
+
worked: list[str] = field(default_factory=list)
|
|
59
|
+
failed: list[str] = field(default_factory=list)
|
|
60
|
+
applicable_when: list[str] = field(default_factory=list)
|
|
61
|
+
confidence: float = 0.5
|
|
62
|
+
source_task: str = ""
|
|
63
|
+
created_at: str = field(default_factory=_now)
|
|
64
|
+
|
|
65
|
+
def to_json(self):
|
|
66
|
+
return json.dumps(asdict(self), ensure_ascii=False)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class LearningStore:
|
|
70
|
+
def __init__(self, workspace: str | Path, *, session_id: str | None = None,
|
|
71
|
+
max_lessons: int = LearningConfig().max_lessons,
|
|
72
|
+
session_dir: str | Path | None = None,
|
|
73
|
+
global_root: str | Path | None = None):
|
|
74
|
+
self.workspace = Path(workspace).resolve()
|
|
75
|
+
self.session_id = session_id
|
|
76
|
+
self.session_dir = Path(session_dir).resolve() if session_dir is not None else None
|
|
77
|
+
# Session facts are markdown under the session bundle, never the project.
|
|
78
|
+
# With no session yet, keep the same machinery in a workspace-local dir
|
|
79
|
+
# so tools and tests can still record before a session id exists.
|
|
80
|
+
if self.session_dir is not None:
|
|
81
|
+
self.memory_dir = safe_path(self.session_dir, "memories")
|
|
82
|
+
else:
|
|
83
|
+
self.memory_dir = safe_path(self.workspace, ".symphony/memories")
|
|
84
|
+
# Do not resolve away symlinks before validating paths.
|
|
85
|
+
self.global_root = Path(global_root).absolute() if global_root is not None else safe_path(Path.home(), ".symphony/memories/global")
|
|
86
|
+
self.roots = {"workspace": self.memory_dir, "global": self.global_root}
|
|
87
|
+
self.path = safe_path(self.memory_dir, "lessons.jsonl")
|
|
88
|
+
# Markdown fact files. These are the session (and global) record.
|
|
89
|
+
self.memory_path = safe_path(self.memory_dir, "MEMORY.md")
|
|
90
|
+
self.user_path = safe_path(self.global_root, "USER.md")
|
|
91
|
+
self.max_lessons = max_lessons
|
|
92
|
+
self._lock = threading.RLock()
|
|
93
|
+
self._lock_depth = 0
|
|
94
|
+
for root in self.roots.values():
|
|
95
|
+
# Validate every ancestor, including a supplied global root.
|
|
96
|
+
for parent in (root, *root.parents):
|
|
97
|
+
if parent.is_symlink():
|
|
98
|
+
raise ValueError("unsafe memory root")
|
|
99
|
+
for relative in ("topics", "observations/_inbox", "observations/archive", "jobs"):
|
|
100
|
+
safe_path(root, relative).mkdir(parents=True, exist_ok=True)
|
|
101
|
+
self.archive_memory_snapshot()
|
|
102
|
+
|
|
103
|
+
def _root(self, scope):
|
|
104
|
+
if scope not in self.roots:
|
|
105
|
+
raise ValueError("scope must be workspace or global")
|
|
106
|
+
return self.roots[scope]
|
|
107
|
+
|
|
108
|
+
@contextmanager
|
|
109
|
+
def _locked(self):
|
|
110
|
+
with self._lock:
|
|
111
|
+
if self._lock_depth:
|
|
112
|
+
yield
|
|
113
|
+
return
|
|
114
|
+
handles = []
|
|
115
|
+
try:
|
|
116
|
+
for root in sorted(set(self.roots.values()), key=str):
|
|
117
|
+
path = safe_path(root, ".lock")
|
|
118
|
+
fd = os.open(path, os.O_CREAT | os.O_RDWR | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
119
|
+
handles.append(fd)
|
|
120
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
121
|
+
self._lock_depth = 1
|
|
122
|
+
yield
|
|
123
|
+
finally:
|
|
124
|
+
self._lock_depth = 0
|
|
125
|
+
for fd in reversed(handles):
|
|
126
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
127
|
+
os.close(fd)
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _read(path):
|
|
131
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def _write(path, value):
|
|
135
|
+
publish(path, (json.dumps(value, ensure_ascii=False, sort_keys=True) + "\n").encode())
|
|
136
|
+
|
|
137
|
+
def _topic_records(self, scope):
|
|
138
|
+
root = self._root(scope)
|
|
139
|
+
for path in sorted(safe_path(root, "topics").glob("*.json")):
|
|
140
|
+
yield self._read(safe_path(root, "topics", _id(path.stem) + ".json"))
|
|
141
|
+
|
|
142
|
+
def _facts(self):
|
|
143
|
+
for scope in self.roots:
|
|
144
|
+
for topic in self._topic_records(scope):
|
|
145
|
+
for fact in topic.get("facts", []):
|
|
146
|
+
yield {"id": topic["id"], "title": topic.get("title", topic["id"]),
|
|
147
|
+
"text": sanitize_text(fact["text"], max_chars=2000),
|
|
148
|
+
"scope": scope, "fact_id": fact["id"]}
|
|
149
|
+
|
|
150
|
+
def _candidate(self, text, topic="general", scope="workspace", sources=None):
|
|
151
|
+
_id(topic)
|
|
152
|
+
self._root(scope)
|
|
153
|
+
text = sanitize_text(str(text), max_chars=2000)
|
|
154
|
+
if not text:
|
|
155
|
+
raise ValueError("observation requires non-empty text")
|
|
156
|
+
sources = [sanitize_text(str(s), max_chars=500) for s in (sources or [])[:20]]
|
|
157
|
+
key = _hash([scope, topic, text.casefold(), sources])
|
|
158
|
+
return {"id": key, "text": text, "topic": topic, "scope": scope,
|
|
159
|
+
"sources": sources, "created_at": _now()}
|
|
160
|
+
|
|
161
|
+
def _record(self, candidate):
|
|
162
|
+
root = self._root(candidate["scope"])
|
|
163
|
+
name = candidate["id"] + ".json"
|
|
164
|
+
path = safe_path(root, "observations/_inbox", name)
|
|
165
|
+
if not path.exists() and not safe_path(root, "observations/archive", name).exists():
|
|
166
|
+
self._write(path, candidate)
|
|
167
|
+
return candidate["id"]
|
|
168
|
+
|
|
169
|
+
def record_observation(self, text, topic="general", scope="workspace", sources=None):
|
|
170
|
+
candidate = self._candidate(text, topic, scope, sources)
|
|
171
|
+
with self._locked():
|
|
172
|
+
return self._record(candidate)
|
|
173
|
+
|
|
174
|
+
def enqueue_capture(self, task, messages, session_id):
|
|
175
|
+
def clean(value):
|
|
176
|
+
if is_dataclass(value):
|
|
177
|
+
value = asdict(value)
|
|
178
|
+
elif hasattr(value, "model_dump"):
|
|
179
|
+
value = value.model_dump(mode="json")
|
|
180
|
+
if isinstance(value, dict):
|
|
181
|
+
return {str(k): clean(v) for k, v in value.items()}
|
|
182
|
+
if isinstance(value, (list, tuple)):
|
|
183
|
+
return [clean(v) for v in value]
|
|
184
|
+
if isinstance(value, str):
|
|
185
|
+
return sanitize_memory(value, max_chars=16000)
|
|
186
|
+
if value is None or isinstance(value, (int, float, bool)):
|
|
187
|
+
return value
|
|
188
|
+
return sanitize_text(str(value), max_chars=16000)
|
|
189
|
+
payload = {"task": sanitize_task(task), "messages": clean(messages),
|
|
190
|
+
"session_id": sanitize_text(str(session_id or ""), max_chars=128)}
|
|
191
|
+
job_id = _hash(payload)
|
|
192
|
+
with self._locked():
|
|
193
|
+
path = safe_path(self.memory_dir, "jobs", job_id + ".json")
|
|
194
|
+
if not path.exists():
|
|
195
|
+
self._write(path, {"id": job_id, **payload, "status": "pending", "created_at": _now()})
|
|
196
|
+
self._archive_record("learning/captures.jsonl", {"job_id": job_id, "status": "queued"})
|
|
197
|
+
return job_id
|
|
198
|
+
|
|
199
|
+
def pending_jobs(self):
|
|
200
|
+
with self._locked():
|
|
201
|
+
jobs = [self._read(safe_path(self.memory_dir, "jobs", _id(p.stem) + ".json"))
|
|
202
|
+
for p in sorted(safe_path(self.memory_dir, "jobs").glob("*.json"))]
|
|
203
|
+
return [job for job in jobs if job.get("status") not in {"complete", "failed"}]
|
|
204
|
+
|
|
205
|
+
def record_capture_failure(self, id, error):
|
|
206
|
+
"""Count one bad extraction; return True once the job is marked failed."""
|
|
207
|
+
path = safe_path(self.memory_dir, "jobs", _id(id) + ".json")
|
|
208
|
+
with self._locked():
|
|
209
|
+
job = self._read(path)
|
|
210
|
+
attempts = int(job.get("attempts", 0)) + 1
|
|
211
|
+
status = "failed" if attempts >= MAX_CAPTURE_ATTEMPTS else job.get("status", "pending")
|
|
212
|
+
error = sanitize_text(str(error), max_chars=400)
|
|
213
|
+
self._write(path, {**job, "attempts": attempts, "status": status, "last_error": error})
|
|
214
|
+
self._archive_record("learning/captures.jsonl", {"job_id": id, "status": status,
|
|
215
|
+
"attempts": attempts, "error": error})
|
|
216
|
+
return status == "failed"
|
|
217
|
+
|
|
218
|
+
def complete_job(self, id, observations):
|
|
219
|
+
path = safe_path(self.memory_dir, "jobs", _id(id) + ".json")
|
|
220
|
+
with self._locked():
|
|
221
|
+
job = self._read(path)
|
|
222
|
+
if job.get("status") == "complete":
|
|
223
|
+
return
|
|
224
|
+
# Validate the entire batch before publishing any candidate.
|
|
225
|
+
candidates = [self._candidate(o["text"], o.get("topic", "general"),
|
|
226
|
+
o.get("scope", "workspace"), o.get("sources", [id]))
|
|
227
|
+
for o in observations]
|
|
228
|
+
ids = [self._record(c) for c in candidates]
|
|
229
|
+
self._write(path, {**job, "status": "complete", "observation_ids": ids, "completed_at": _now()})
|
|
230
|
+
self._archive_record("learning/captures.jsonl", {"job_id": id, "status": "complete", "observation_ids": ids})
|
|
231
|
+
|
|
232
|
+
def _consolidate(self):
|
|
233
|
+
stats = {"processed": 0, "added": 0, "duplicates": 0, "archived": 0}
|
|
234
|
+
for scope, root in self.roots.items():
|
|
235
|
+
known = {f["text"].casefold() for t in self._topic_records(scope) for f in t.get("facts", [])}
|
|
236
|
+
for file in sorted(safe_path(root, "observations/_inbox").glob("*.json")):
|
|
237
|
+
inbox = safe_path(root, "observations/_inbox", _id(file.stem) + ".json")
|
|
238
|
+
record = self._read(inbox)
|
|
239
|
+
topic_id = _id(record["topic"])
|
|
240
|
+
path = safe_path(root, "topics", topic_id + ".json")
|
|
241
|
+
topic = self._read(path) if path.exists() else {"id": topic_id, "title": topic_id.replace("-", " "), "scope": scope, "facts": []}
|
|
242
|
+
text = sanitize_text(record["text"], max_chars=2000)
|
|
243
|
+
stats["processed"] += 1
|
|
244
|
+
if text.casefold() not in known:
|
|
245
|
+
topic["facts"].append({"id": _hash([scope, text.casefold()]), "text": text,
|
|
246
|
+
"sources": record.get("sources", []), "created_at": record["created_at"]})
|
|
247
|
+
topic["updated_at"] = _now()
|
|
248
|
+
self._write(path, topic) # Commit precedes inbox archival, always.
|
|
249
|
+
known.add(text.casefold())
|
|
250
|
+
stats["added"] += 1
|
|
251
|
+
else:
|
|
252
|
+
# Repeated knowledge still belongs to every session that
|
|
253
|
+
# supplied it; merge evidence links without duplicating text.
|
|
254
|
+
for existing in self._topic_records(scope):
|
|
255
|
+
for fact in existing.get("facts", []):
|
|
256
|
+
if fact["text"].casefold() == text.casefold():
|
|
257
|
+
sources = sorted(set(fact.get("sources", [])) | set(record.get("sources", [])))
|
|
258
|
+
if sources != fact.get("sources", []):
|
|
259
|
+
fact["sources"] = sources
|
|
260
|
+
self._write(safe_path(root, "topics", existing["id"] + ".json"), existing)
|
|
261
|
+
stats["duplicates"] += 1
|
|
262
|
+
archive = safe_path(root, "observations/archive", file.name)
|
|
263
|
+
os.replace(inbox, archive)
|
|
264
|
+
fsync_dir(archive.parent)
|
|
265
|
+
fsync_dir(inbox.parent)
|
|
266
|
+
stats["archived"] += 1
|
|
267
|
+
return stats
|
|
268
|
+
|
|
269
|
+
def consolidate(self):
|
|
270
|
+
with self._locked():
|
|
271
|
+
return self._consolidate()
|
|
272
|
+
|
|
273
|
+
@staticmethod
|
|
274
|
+
def _query_words(text):
|
|
275
|
+
return {w for w in re.findall(r"[a-z0-9][a-z0-9_-]{2,}", sanitize_memory(text, max_chars=1200).casefold()) if w not in _STOP_WORDS}
|
|
276
|
+
|
|
277
|
+
def search(self, query, limit=6):
|
|
278
|
+
if limit <= 0:
|
|
279
|
+
return []
|
|
280
|
+
words = sorted(self._query_words(query))
|
|
281
|
+
if not words:
|
|
282
|
+
return []
|
|
283
|
+
with self._locked():
|
|
284
|
+
facts = list(self._facts())
|
|
285
|
+
# Rebuild on every read: durable topics, including other processes'
|
|
286
|
+
# writes, are always authoritative. No embeddings or model calls.
|
|
287
|
+
index = safe_path(self.memory_dir, "index.sqlite3")
|
|
288
|
+
with sqlite3.connect(index) as db:
|
|
289
|
+
try:
|
|
290
|
+
db.execute("CREATE VIRTUAL TABLE IF NOT EXISTS facts USING fts5(id UNINDEXED, title, text, scope UNINDEXED)")
|
|
291
|
+
db.execute("DELETE FROM facts")
|
|
292
|
+
db.executemany("INSERT INTO facts VALUES (?,?,?,?)", [(f["id"], f["title"], f["text"], f["scope"]) for f in facts])
|
|
293
|
+
rows = db.execute("SELECT id,title,text,scope,bm25(facts) FROM facts WHERE facts MATCH ? ORDER BY bm25(facts),rowid LIMIT ?", (' OR '.join('"' + w + '"' for w in words), limit)).fetchall()
|
|
294
|
+
return [dict(id=r[0], title=r[1], text=r[2], scope=r[3], score=-r[4]) for r in rows]
|
|
295
|
+
except sqlite3.OperationalError as exc:
|
|
296
|
+
if "no such module" not in str(exc):
|
|
297
|
+
raise
|
|
298
|
+
ranked = [{k: f[k] for k in ("id", "title", "text", "scope")} | {"score": len(set(words) & self._query_words(f["title"] + " " + f["text"]))} for f in facts]
|
|
299
|
+
return sorted([r for r in ranked if r["score"]], key=lambda r: r["score"], reverse=True)[:limit]
|
|
300
|
+
|
|
301
|
+
def get_topic(self, id, scope="workspace"):
|
|
302
|
+
path = safe_path(self._root(scope), "topics", _id(id) + ".json")
|
|
303
|
+
with self._locked():
|
|
304
|
+
if not path.exists():
|
|
305
|
+
return ""
|
|
306
|
+
topic = self._read(path)
|
|
307
|
+
return sanitize_memory("# " + topic.get("title", id) + "\n\n" + "\n".join("- " + f["text"] for f in topic.get("facts", [])), max_chars=20000)
|
|
308
|
+
|
|
309
|
+
def memory_operation(self, action, *, target="memory", text="", match=""):
|
|
310
|
+
try:
|
|
311
|
+
return self._memory_operation(action, target=target, text=text, match=match)
|
|
312
|
+
except ValueError as exc:
|
|
313
|
+
self._archive_record("memory/operations.jsonl", {"action": sanitize_text(action), "target": sanitize_text(target),
|
|
314
|
+
"text": sanitize_text(text, max_chars=600), "match": sanitize_text(match), "status": "error", "result": sanitize_text(str(exc))})
|
|
315
|
+
raise
|
|
316
|
+
|
|
317
|
+
def _memory_operation(self, action, *, target="memory", text="", match=""):
|
|
318
|
+
action = action.strip().lower()
|
|
319
|
+
if target not in {"memory", "user"}:
|
|
320
|
+
raise ValueError("target must be memory or user")
|
|
321
|
+
if action not in {"add", "replace", "remove"}:
|
|
322
|
+
raise ValueError("action must be add, replace, or remove")
|
|
323
|
+
scope, topic_id = ("global", "preferences") if target == "user" else ("workspace", "general")
|
|
324
|
+
clean = sanitize_text(text.strip(), max_chars=600)
|
|
325
|
+
with self._locked():
|
|
326
|
+
if action == "add":
|
|
327
|
+
if not clean:
|
|
328
|
+
raise ValueError("add requires non-empty text")
|
|
329
|
+
self._record(self._candidate(clean, topic_id, scope, ["explicit-user", _now()]))
|
|
330
|
+
stats = self._consolidate()
|
|
331
|
+
result = "added memory entry" if stats["added"] else f"{target} unchanged: duplicate entry"
|
|
332
|
+
else:
|
|
333
|
+
# Drain pending facts first so mutations cannot be undone by a
|
|
334
|
+
# candidate that existed before the explicit edit.
|
|
335
|
+
self._consolidate()
|
|
336
|
+
topics = list(self._topic_records(scope))
|
|
337
|
+
found = [(t, i) for t in topics for i, f in enumerate(t["facts"]) if match and match in f["text"] and (target != "user" or t["id"] == "preferences")]
|
|
338
|
+
if len(found) != 1:
|
|
339
|
+
raise ValueError(f"match must identify exactly one entry; matches: {len(found)}")
|
|
340
|
+
topic, position = found[0]
|
|
341
|
+
if action == "remove":
|
|
342
|
+
topic["facts"].pop(position)
|
|
343
|
+
else:
|
|
344
|
+
if not clean:
|
|
345
|
+
raise ValueError("replace requires non-empty text")
|
|
346
|
+
topic["facts"][position] = {**topic["facts"][position], "id": _hash([scope, clean.casefold()]), "text": clean}
|
|
347
|
+
topic["updated_at"] = _now()
|
|
348
|
+
self._write(safe_path(self._root(scope), "topics", topic["id"] + ".json"), topic)
|
|
349
|
+
result = f"{target} updated: {action}"
|
|
350
|
+
self.archive_memory_snapshot()
|
|
351
|
+
self._archive_record("memory/operations.jsonl", {"action": action, "target": target, "text": clean, "match": sanitize_text(match), "status": "ok", "result": result})
|
|
352
|
+
return result
|
|
353
|
+
|
|
354
|
+
def _query_entries(self):
|
|
355
|
+
with self._locked():
|
|
356
|
+
return [MemoryEntry("user" if f["scope"] == "global" and f["id"] == "preferences" else "memory", f["text"], i) for i, f in enumerate(self._facts())]
|
|
357
|
+
|
|
358
|
+
def _query(self, task, *, limit=6, max_chars=1400, recent_context="", include_preferences=False):
|
|
359
|
+
if limit <= 0 or max_chars <= len(MEMORY_CONTEXT_PREFIX) + 1:
|
|
360
|
+
return ""
|
|
361
|
+
current = self.search(task, limit=max(100, limit))
|
|
362
|
+
recent = self.search(recent_context, limit=max(100, limit)) if recent_context else []
|
|
363
|
+
# Current task matches outrank recent-context-only matches.
|
|
364
|
+
candidates = current + recent
|
|
365
|
+
preferences = []
|
|
366
|
+
if include_preferences:
|
|
367
|
+
with self._locked():
|
|
368
|
+
preferences = [f for f in self._facts() if f["scope"] == "global" and f["id"] == "preferences"][:2]
|
|
369
|
+
selected, seen = [], set()
|
|
370
|
+
used = len(MEMORY_CONTEXT_PREFIX) + 1
|
|
371
|
+
preference_budget = min(400, (max_chars - used) // 3)
|
|
372
|
+
for fact in preferences + candidates:
|
|
373
|
+
text = sanitize_memory(fact["text"], max_chars=max_chars)
|
|
374
|
+
line = "- " + text
|
|
375
|
+
pref = fact in preferences
|
|
376
|
+
if not text or text.casefold() in seen or len(selected) >= limit:
|
|
377
|
+
continue
|
|
378
|
+
if used + len(line) + 1 > max_chars or (pref and len(line) + 1 > preference_budget):
|
|
379
|
+
continue
|
|
380
|
+
selected.append(line)
|
|
381
|
+
seen.add(text.casefold())
|
|
382
|
+
used += len(line) + 1
|
|
383
|
+
if pref:
|
|
384
|
+
preference_budget -= len(line) + 1
|
|
385
|
+
return MEMORY_CONTEXT_PREFIX + "\n" + "\n".join(selected) if selected else ""
|
|
386
|
+
|
|
387
|
+
def query(self, task, *, limit=6, max_chars=1400, recent_context="", include_preferences=False):
|
|
388
|
+
context = self._query(task, limit=limit, max_chars=max_chars, recent_context=recent_context, include_preferences=include_preferences)
|
|
389
|
+
self.archive_memory_snapshot()
|
|
390
|
+
self.archive_context("queried", task, context)
|
|
391
|
+
return context
|
|
392
|
+
|
|
393
|
+
def context_for(self, task, *, limit=6, max_chars=1400):
|
|
394
|
+
return self.query(task, limit=limit, max_chars=max_chars)
|
|
395
|
+
|
|
396
|
+
def snapshot(self, *, max_chars=1400):
|
|
397
|
+
if max_chars <= len(MEMORY_CONTEXT_PREFIX) + 1:
|
|
398
|
+
return ""
|
|
399
|
+
with self._locked():
|
|
400
|
+
facts = list(self._facts())
|
|
401
|
+
if not facts:
|
|
402
|
+
return ""
|
|
403
|
+
return sanitize_memory(MEMORY_CONTEXT_PREFIX + "\n" + "\n".join("- " + f["text"] for f in facts), max_chars=max_chars)
|
|
404
|
+
|
|
405
|
+
def append_legacy_summary(self, summary, *, source_task=""):
|
|
406
|
+
self.append(Lesson(summary=summary, source_task=source_task))
|
|
407
|
+
|
|
408
|
+
def append(self, lesson):
|
|
409
|
+
clean = Lesson(summary=sanitize_text(lesson.summary, max_chars=240),
|
|
410
|
+
worked=[sanitize_text(s, max_chars=180) for s in lesson.worked[:6]],
|
|
411
|
+
failed=[sanitize_text(s, max_chars=180) for s in lesson.failed[:6]],
|
|
412
|
+
applicable_when=[sanitize_text(s, max_chars=120) for s in lesson.applicable_when[:6]],
|
|
413
|
+
confidence=max(0, min(1, lesson.confidence)), source_task=sanitize_task(lesson.source_task),
|
|
414
|
+
created_at=sanitize_text(lesson.created_at, max_chars=80))
|
|
415
|
+
with self._locked():
|
|
416
|
+
lessons = [lesson for lesson in self.load() if lesson.summary.casefold() != clean.summary.casefold()] + [clean]
|
|
417
|
+
self._rewrite(lessons[-self.max_lessons:])
|
|
418
|
+
self._archive_record("learning/lessons.jsonl", asdict(clean))
|
|
419
|
+
|
|
420
|
+
def load(self):
|
|
421
|
+
lessons = []
|
|
422
|
+
with self._locked():
|
|
423
|
+
if not self.path.exists():
|
|
424
|
+
return []
|
|
425
|
+
for line in self.path.read_text(encoding="utf-8").splitlines():
|
|
426
|
+
try:
|
|
427
|
+
lessons.append(Lesson(**json.loads(line)))
|
|
428
|
+
except (ValueError, TypeError):
|
|
429
|
+
continue
|
|
430
|
+
return lessons
|
|
431
|
+
|
|
432
|
+
def _rewrite(self, lessons):
|
|
433
|
+
publish(self.path, ("".join(lesson.to_json() + "\n" for lesson in lessons)).encode())
|
|
434
|
+
|
|
435
|
+
def to_markdown(self):
|
|
436
|
+
with self._locked():
|
|
437
|
+
facts = list(self._facts())
|
|
438
|
+
lessons = self.load()
|
|
439
|
+
jobs = self.pending_jobs()
|
|
440
|
+
with self._locked():
|
|
441
|
+
inbox = sum(len(list(safe_path(root, "observations/_inbox").glob("*.json"))) for root in self.roots.values())
|
|
442
|
+
if not facts and not lessons and not jobs and not inbox:
|
|
443
|
+
return "# Agent learnings\n\n_No lessons stored yet for this workspace._\n"
|
|
444
|
+
lines = ["# Agent learnings", "", "_Durable topics in this session's `memories/`. Global preferences live in `~/.symphony/memories/global/`._", "",
|
|
445
|
+
f"Pending capture jobs: {len(jobs)}; observations awaiting consolidation: {inbox}", ""]
|
|
446
|
+
lines.extend(f"- Capture `{job['id']}` · session `{job.get('session_id', '')}`" for job in jobs)
|
|
447
|
+
lines.extend(f"- [{f['scope']}/{f['id']}] {f['text']}" for f in facts)
|
|
448
|
+
for lesson in reversed(lessons):
|
|
449
|
+
lines.extend(["", "## " + lesson.summary, f"- **Confidence:** {lesson.confidence:.2f}", "- **Source task:** " + lesson.source_task])
|
|
450
|
+
return sanitize_memory("\n".join(lines), max_chars=100000) + "\n"
|
|
451
|
+
|
|
452
|
+
def scoped_markdown(self, scope, *, session_id=None):
|
|
453
|
+
"""Present actual source provenance, never label workspace facts as session facts."""
|
|
454
|
+
if scope not in {"session", "global"}:
|
|
455
|
+
raise ValueError("view must be session or global")
|
|
456
|
+
session_id = session_id or self.session_id
|
|
457
|
+
lines = ["# Global memory" if scope == "global" else "# Session memory", ""]
|
|
458
|
+
with self._locked():
|
|
459
|
+
if scope == "global":
|
|
460
|
+
topics = list(self._topic_records("global"))
|
|
461
|
+
facts = [(topic, fact) for topic in topics for fact in topic.get("facts", [])]
|
|
462
|
+
jobs = []
|
|
463
|
+
else:
|
|
464
|
+
jobs = [self._read(safe_path(self.memory_dir, "jobs", _id(path.stem) + ".json"))
|
|
465
|
+
for path in sorted(safe_path(self.memory_dir, "jobs").glob("*.json"))]
|
|
466
|
+
jobs = [job for job in jobs if session_id and job.get("session_id") == session_id]
|
|
467
|
+
job_ids = {job["id"] for job in jobs}
|
|
468
|
+
facts = [(topic, fact) for topic in self._topic_records("workspace")
|
|
469
|
+
for fact in topic.get("facts", []) if job_ids.intersection(fact.get("sources", []))]
|
|
470
|
+
lines.extend([f"Session: {session_id or '(no active session)'}", "",
|
|
471
|
+
f"Captures: {len(jobs)} · pending: {sum(job.get('status') != 'complete' for job in jobs)}", ""])
|
|
472
|
+
# Explicit session memory edits have provenance in the session audit,
|
|
473
|
+
# not in an extraction job. Include those separately from captured facts.
|
|
474
|
+
if self.session_dir is not None and session_id == self.session_id:
|
|
475
|
+
audit = safe_path(self.session_dir, "memory/operations.jsonl")
|
|
476
|
+
if audit.is_file():
|
|
477
|
+
for raw in audit.read_text(encoding="utf-8").splitlines():
|
|
478
|
+
try:
|
|
479
|
+
operation = json.loads(raw)
|
|
480
|
+
except ValueError:
|
|
481
|
+
continue
|
|
482
|
+
if operation.get("status") == "ok" and operation.get("target") == "memory":
|
|
483
|
+
lines.append(f"- Memory edit ({operation.get('action', '')}): {sanitize_text(operation.get('text') or operation.get('match', ''), max_chars=600)}")
|
|
484
|
+
for topic, fact in facts:
|
|
485
|
+
lines.extend([f"## {topic.get('title', topic['id'])}", "", sanitize_memory(fact["text"], max_chars=2000), ""])
|
|
486
|
+
if not facts:
|
|
487
|
+
lines.append("No global facts saved yet." if scope == "global" else "No curated facts captured from this session yet.")
|
|
488
|
+
for job in jobs:
|
|
489
|
+
lines.append(f"- Capture {job['id'][:12]} · {job.get('status', 'pending')}")
|
|
490
|
+
return sanitize_memory("\n".join(lines), max_chars=100000) + "\n"
|
|
491
|
+
|
|
492
|
+
def _archive_record(self, relative_path, record):
|
|
493
|
+
if self.session_dir is None:
|
|
494
|
+
return
|
|
495
|
+
try:
|
|
496
|
+
append_bytes(safe_path(self.session_dir, relative_path), (json.dumps({"created_at": _now(), **record}, ensure_ascii=False) + "\n").encode())
|
|
497
|
+
except (OSError, ValueError):
|
|
498
|
+
logger.warning("Could not archive session memory record", exc_info=True)
|
|
499
|
+
|
|
500
|
+
def archive_memory_snapshot(self):
|
|
501
|
+
if self.session_dir is None:
|
|
502
|
+
return
|
|
503
|
+
try:
|
|
504
|
+
with self._locked():
|
|
505
|
+
facts = list(self._facts())
|
|
506
|
+
for target, scope, root, limit in (
|
|
507
|
+
("MEMORY.md", "workspace", self.memory_dir, 3000),
|
|
508
|
+
("USER.md", "global", self.global_root, 1500),
|
|
509
|
+
):
|
|
510
|
+
raw = "\n".join("- " + f["text"] for f in facts if f["scope"] == scope)
|
|
511
|
+
publish(safe_path(root, target), sanitize_memory(raw, max_chars=limit).encode())
|
|
512
|
+
except (OSError, ValueError, UnicodeError):
|
|
513
|
+
logger.warning("Could not archive session memory snapshot", exc_info=True)
|
|
514
|
+
|
|
515
|
+
def archive_context(self, kind, task, context):
|
|
516
|
+
self._archive_record("learning/context.jsonl", {"kind": kind, "source": "durable topical memory (untrusted reference data)", "task": sanitize_task(task), "context": sanitize_memory(context, max_chars=max(1400, len(context)))})
|
|
517
|
+
|
|
518
|
+
def bind_session(self, session_id, session_dir=None):
|
|
519
|
+
self.session_id = session_id
|
|
520
|
+
if session_dir is not None:
|
|
521
|
+
self.session_dir = Path(session_dir).resolve()
|
|
522
|
+
self.archive_memory_snapshot()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""User addons from ``~/.symphony/addons`` merged into the coding agent."""
|
|
2
|
+
|
|
3
|
+
from coding_agent.addons.manager.bridge import AddonSurface, build_surface
|
|
4
|
+
from coding_agent.addons.manager.discovery import BUILTIN_ADDON_NAMES, load_user_addons, user_addons_root
|
|
5
|
+
from coding_agent.addons.manager.report import addons_report
|
|
6
|
+
from coding_agent.addons.manager.runtime import UserAddonRuntime
|
|
7
|
+
from coding_agent.addons.manager.tool_changes import tool_change_notice
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"BUILTIN_ADDON_NAMES",
|
|
11
|
+
"AddonSurface",
|
|
12
|
+
"UserAddonRuntime",
|
|
13
|
+
"addons_report",
|
|
14
|
+
"build_surface",
|
|
15
|
+
"load_user_addons",
|
|
16
|
+
"tool_change_notice",
|
|
17
|
+
"user_addons_root",
|
|
18
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Merge a user-addon snapshot with the built-in tools and commands.
|
|
2
|
+
|
|
3
|
+
Built-in names always win. A user tool or command that reuses one, or
|
|
4
|
+
that another user addon already took, is dropped with a notice.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Collection
|
|
11
|
+
|
|
12
|
+
from core_harness import Tool
|
|
13
|
+
from core_harness.extensions import CommandSpec, ExtensionSnapshot
|
|
14
|
+
|
|
15
|
+
from coding_agent.commands.catalog import SLASH_COMMANDS, SlashCommand
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class AddonSurface:
|
|
20
|
+
"""What the agent actually exposes from one snapshot."""
|
|
21
|
+
|
|
22
|
+
snapshot: ExtensionSnapshot
|
|
23
|
+
tools: tuple[Tool, ...]
|
|
24
|
+
commands: tuple[CommandSpec, ...]
|
|
25
|
+
notices: tuple[str, ...]
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def tool_names(self) -> frozenset[str]:
|
|
29
|
+
return frozenset(tool.name for tool in self.tools)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def slash_commands(self) -> tuple[SlashCommand, ...]:
|
|
33
|
+
return tuple(SlashCommand(item.name, item.description, item.argument) for item in self.commands)
|
|
34
|
+
|
|
35
|
+
def command(self, name: str) -> CommandSpec | None:
|
|
36
|
+
for item in self.commands:
|
|
37
|
+
if item.name == name:
|
|
38
|
+
return item
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
BUILTIN_COMMAND_NAMES: frozenset[str] = frozenset(
|
|
43
|
+
{item.name for item in SLASH_COMMANDS} | {"exit", "extensions", "plugins", "skills"}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_surface(
|
|
48
|
+
snapshot: ExtensionSnapshot,
|
|
49
|
+
*,
|
|
50
|
+
builtin_tool_names: Collection[str],
|
|
51
|
+
builtin_command_names: Collection[str] = BUILTIN_COMMAND_NAMES,
|
|
52
|
+
) -> AddonSurface:
|
|
53
|
+
notices = list(snapshot.notices)
|
|
54
|
+
tools: list[Tool] = []
|
|
55
|
+
commands: list[CommandSpec] = []
|
|
56
|
+
taken_tools = set(builtin_tool_names)
|
|
57
|
+
taken_commands = set(builtin_command_names)
|
|
58
|
+
for addon in snapshot.loaded:
|
|
59
|
+
for tool in addon.tools:
|
|
60
|
+
if tool.name in taken_tools:
|
|
61
|
+
notices.append(f"addon {addon.name!r}: tool {tool.name!r} is already taken and was not added")
|
|
62
|
+
continue
|
|
63
|
+
taken_tools.add(tool.name)
|
|
64
|
+
tools.append(tool)
|
|
65
|
+
for command in addon.commands:
|
|
66
|
+
if command.name in taken_commands:
|
|
67
|
+
notices.append(f"addon {addon.name!r}: /{command.name} is already taken and was not added")
|
|
68
|
+
continue
|
|
69
|
+
taken_commands.add(command.name)
|
|
70
|
+
commands.append(command)
|
|
71
|
+
return AddonSurface(snapshot, tuple(tools), tuple(commands), tuple(notices))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
__all__ = ["BUILTIN_COMMAND_NAMES", "AddonSurface", "build_surface"]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Where user addons live and which names they may not take.
|
|
2
|
+
|
|
3
|
+
Only ``<symphony home>/addons/<name>/`` is scanned (``SYMPHONY_HOME``
|
|
4
|
+
overrides the home). There is no project-local addon folder.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import pkgutil
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Collection
|
|
12
|
+
|
|
13
|
+
import coding_agent.addons as builtin_addons
|
|
14
|
+
from core_harness.extensions import ExtensionSnapshot, load_addons
|
|
15
|
+
|
|
16
|
+
from coding_agent.config import symphony_dir
|
|
17
|
+
|
|
18
|
+
BUILTIN_ADDON_NAMES: frozenset[str] = frozenset(
|
|
19
|
+
module.name for module in pkgutil.iter_modules(builtin_addons.__path__)
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def user_addons_root() -> Path:
|
|
24
|
+
return symphony_dir() / "addons"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_user_addons(reserved_names: Collection[str] = ()) -> ExtensionSnapshot:
|
|
28
|
+
return load_addons(
|
|
29
|
+
(user_addons_root(),),
|
|
30
|
+
reserved_names=BUILTIN_ADDON_NAMES | frozenset(reserved_names),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
__all__ = ["BUILTIN_ADDON_NAMES", "load_user_addons", "user_addons_root"]
|