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.
Files changed (169) hide show
  1. coding_agent/__init__.py +20 -0
  2. coding_agent/_lazy.py +17 -0
  3. coding_agent/addons/__init__.py +35 -0
  4. coding_agent/addons/approvals.py +224 -0
  5. coding_agent/addons/compaction/__init__.py +36 -0
  6. coding_agent/addons/compaction/addon.py +88 -0
  7. coding_agent/addons/compaction/compactor.py +138 -0
  8. coding_agent/addons/compaction/prompts.py +20 -0
  9. coding_agent/addons/compaction/transcript.py +118 -0
  10. coding_agent/addons/evaluation/__init__.py +68 -0
  11. coding_agent/addons/evaluation/addon.py +199 -0
  12. coding_agent/addons/evaluation/evaluators.py +245 -0
  13. coding_agent/addons/evaluation/handlers.py +66 -0
  14. coding_agent/addons/evaluation/policy.py +277 -0
  15. coding_agent/addons/evaluation/protocol.py +192 -0
  16. coding_agent/addons/evaluation/state.py +284 -0
  17. coding_agent/addons/langfuse/__init__.py +24 -0
  18. coding_agent/addons/langfuse/addon.py +465 -0
  19. coding_agent/addons/langfuse/install.py +79 -0
  20. coding_agent/addons/langfuse/serialize.py +158 -0
  21. coding_agent/addons/learning/__init__.py +33 -0
  22. coding_agent/addons/learning/addon.py +181 -0
  23. coding_agent/addons/learning/loop.py +247 -0
  24. coding_agent/addons/learning/prompts.py +26 -0
  25. coding_agent/addons/learning/sanitize.py +64 -0
  26. coding_agent/addons/learning/store.py +522 -0
  27. coding_agent/addons/manager/__init__.py +18 -0
  28. coding_agent/addons/manager/bridge.py +74 -0
  29. coding_agent/addons/manager/discovery.py +34 -0
  30. coding_agent/addons/manager/report.py +33 -0
  31. coding_agent/addons/manager/runtime.py +76 -0
  32. coding_agent/addons/manager/tool_changes.py +16 -0
  33. coding_agent/addons/persistence/__init__.py +41 -0
  34. coding_agent/addons/persistence/active.py +132 -0
  35. coding_agent/addons/persistence/artifacts.py +110 -0
  36. coding_agent/addons/persistence/collection.py +166 -0
  37. coding_agent/addons/persistence/compatibility.py +30 -0
  38. coding_agent/addons/persistence/images.py +133 -0
  39. coding_agent/addons/persistence/jsonl.py +1195 -0
  40. coding_agent/addons/persistence/presentation.py +104 -0
  41. coding_agent/addons/plugins/__init__.py +19 -0
  42. coding_agent/addons/plugins/install.py +37 -0
  43. coding_agent/addons/plugins/manager.py +140 -0
  44. coding_agent/addons/plugins/models.py +50 -0
  45. coding_agent/addons/skills/__init__.py +28 -0
  46. coding_agent/addons/skills/addon.py +26 -0
  47. coding_agent/addons/skills/args.py +78 -0
  48. coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
  49. coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
  50. coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
  51. coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
  52. coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
  53. coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
  54. coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
  55. coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
  56. coding_agent/addons/skills/frontmatter.py +57 -0
  57. coding_agent/addons/skills/models.py +57 -0
  58. coding_agent/addons/skills/registry.py +109 -0
  59. coding_agent/addons/skills/tool.py +54 -0
  60. coding_agent/addons/voice/__init__.py +5 -0
  61. coding_agent/addons/voice/addon.py +106 -0
  62. coding_agent/addons/voice/credentials.py +42 -0
  63. coding_agent/addons/voice/playback.py +42 -0
  64. coding_agent/addons/voice/speak.py +287 -0
  65. coding_agent/addons/voice/stream.py +651 -0
  66. coding_agent/addons/voice/summary.py +126 -0
  67. coding_agent/agent.py +523 -0
  68. coding_agent/bench/__init__.py +1 -0
  69. coding_agent/bench/cli.py +200 -0
  70. coding_agent/bench/patch.py +78 -0
  71. coding_agent/commands/__init__.py +5 -0
  72. coding_agent/commands/catalog.py +179 -0
  73. coding_agent/commands/jev_argument.py +40 -0
  74. coding_agent/config/__init__.py +51 -0
  75. coding_agent/config/config.py +376 -0
  76. coding_agent/config/credentials.py +153 -0
  77. coding_agent/config/personalities.json +34 -0
  78. coding_agent/config/personalities.py +170 -0
  79. coding_agent/config/prompts.py +67 -0
  80. coding_agent/protocols/__init__.py +5 -0
  81. coding_agent/protocols/acp/README.md +36 -0
  82. coding_agent/protocols/acp/__init__.py +27 -0
  83. coding_agent/protocols/acp/mapping.py +164 -0
  84. coding_agent/protocols/acp/server.py +261 -0
  85. coding_agent/protocols/acp/transport.py +72 -0
  86. coding_agent/protocols/stdio/README.md +36 -0
  87. coding_agent/protocols/stdio/__init__.py +37 -0
  88. coding_agent/protocols/stdio/catalog.py +49 -0
  89. coding_agent/protocols/stdio/commands.py +218 -0
  90. coding_agent/protocols/stdio/server.py +181 -0
  91. coding_agent/protocols/stdio/transport.py +71 -0
  92. coding_agent/py.typed +0 -0
  93. coding_agent/resources/__init__.py +5 -0
  94. coding_agent/resources/usage.py +264 -0
  95. coding_agent/run/__init__.py +1 -0
  96. coding_agent/run/cli.py +99 -0
  97. coding_agent/run/detach.py +37 -0
  98. coding_agent/run/interrupted.py +67 -0
  99. coding_agent/run/log_sink.py +46 -0
  100. coding_agent/tools/__init__.py +73 -0
  101. coding_agent/tools/ask_user.py +61 -0
  102. coding_agent/tools/base.py +136 -0
  103. coding_agent/tools/bash.py +288 -0
  104. coding_agent/tools/bash_jobs.py +116 -0
  105. coding_agent/tools/generate_image.py +123 -0
  106. coding_agent/tools/memory.py +100 -0
  107. coding_agent/tools/patch.py +218 -0
  108. coding_agent/tools/read_file.py +167 -0
  109. coding_agent/tools/search.py +433 -0
  110. coding_agent/tools/web_search.py +151 -0
  111. coding_agent/tools/write_file.py +40 -0
  112. coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
  113. coding_agent/tui/__init__.py +22 -0
  114. coding_agent/tui/__main__.py +122 -0
  115. coding_agent/tui/app.py +456 -0
  116. coding_agent/tui/chrome/__init__.py +33 -0
  117. coding_agent/tui/chrome/footer.py +274 -0
  118. coding_agent/tui/chrome/topbar.py +259 -0
  119. coding_agent/tui/commands/__init__.py +45 -0
  120. coding_agent/tui/commands/catalog.py +35 -0
  121. coding_agent/tui/commands/manager.py +557 -0
  122. coding_agent/tui/composer/__init__.py +18 -0
  123. coding_agent/tui/composer/input.py +219 -0
  124. coding_agent/tui/composer/slash_menu.py +341 -0
  125. coding_agent/tui/composer/surface.py +330 -0
  126. coding_agent/tui/composer/voice.py +267 -0
  127. coding_agent/tui/composer/voice_mode.py +491 -0
  128. coding_agent/tui/runtime/__init__.py +23 -0
  129. coding_agent/tui/runtime/events.py +728 -0
  130. coding_agent/tui/runtime/sink.py +115 -0
  131. coding_agent/tui/runtime/state.py +145 -0
  132. coding_agent/tui/runtime/subagent.py +531 -0
  133. coding_agent/tui/runtime/turn.py +206 -0
  134. coding_agent/tui/screens/__init__.py +54 -0
  135. coding_agent/tui/screens/ask.py +69 -0
  136. coding_agent/tui/screens/context.py +195 -0
  137. coding_agent/tui/screens/dashboard.py +91 -0
  138. coding_agent/tui/screens/diff.py +302 -0
  139. coding_agent/tui/screens/extensions.py +68 -0
  140. coding_agent/tui/screens/file_selector.py +167 -0
  141. coding_agent/tui/screens/history.py +458 -0
  142. coding_agent/tui/screens/langfuse.py +142 -0
  143. coding_agent/tui/screens/learning.py +113 -0
  144. coding_agent/tui/screens/modal.py +131 -0
  145. coding_agent/tui/screens/onboard.py +455 -0
  146. coding_agent/tui/screens/resume.py +163 -0
  147. coding_agent/tui/theme/__init__.py +49 -0
  148. coding_agent/tui/theme/load.py +325 -0
  149. coding_agent/tui/theme/style.py +95 -0
  150. coding_agent/tui/theme/theme.toml +1687 -0
  151. coding_agent/tui/tools/__init__.py +53 -0
  152. coding_agent/tui/tools/activity.py +184 -0
  153. coding_agent/tui/tools/calls.py +560 -0
  154. coding_agent/tui/tools/code_view.py +52 -0
  155. coding_agent/tui/tools/images.py +250 -0
  156. coding_agent/tui/tools/labels.py +135 -0
  157. coding_agent/tui/tools/modal.py +137 -0
  158. coding_agent/tui/tools/partial_args.py +19 -0
  159. coding_agent/tui/tools/snapshots.py +382 -0
  160. coding_agent/tui/transcript/__init__.py +36 -0
  161. coding_agent/tui/transcript/messages.py +408 -0
  162. coding_agent/tui/transcript/process.py +504 -0
  163. coding_agent/tui/transcript/surface.py +345 -0
  164. coding_agent/tui/transcript/thinking_markdown.py +314 -0
  165. symphony_code-0.0.1.dist-info/METADATA +66 -0
  166. symphony_code-0.0.1.dist-info/RECORD +169 -0
  167. symphony_code-0.0.1.dist-info/WHEEL +4 -0
  168. symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
  169. 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"]