agents-memory 0.42__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.
- agent_memory/__init__.py +18 -0
- agent_memory/__main__.py +92 -0
- agent_memory/cli_help.py +166 -0
- agent_memory/consolidate.py +22 -0
- agent_memory/extract_openai.py +102 -0
- agent_memory/ingest.py +110 -0
- agent_memory/ingest_catalog.py +346 -0
- agent_memory/ingest_chats.py +14 -0
- agent_memory/ingest_common.py +170 -0
- agent_memory/ingest_config.py +245 -0
- agent_memory/ingest_extractors.py +374 -0
- agent_memory/inventory.py +101 -0
- agent_memory/mcp_server.py +313 -0
- agent_memory/store.py +2211 -0
- agent_memory/sync.py +67 -0
- agents_memory-0.42.dist-info/METADATA +148 -0
- agents_memory-0.42.dist-info/RECORD +21 -0
- agents_memory-0.42.dist-info/WHEEL +5 -0
- agents_memory-0.42.dist-info/entry_points.txt +2 -0
- agents_memory-0.42.dist-info/licenses/LICENSE +21 -0
- agents_memory-0.42.dist-info/top_level.txt +1 -0
agent_memory/store.py
ADDED
|
@@ -0,0 +1,2211 @@
|
|
|
1
|
+
"""Local markdown memory store. No cloud.
|
|
2
|
+
|
|
3
|
+
Two layers, one retrieval:
|
|
4
|
+
|
|
5
|
+
- User: ``~/.agents/memory`` — identity, project map, concepts/entities/workflows,
|
|
6
|
+
project **links**, personal notes, chat index.
|
|
7
|
+
- Project: ``<repo>/.agents/memory`` — staging (inbox), research, sequential
|
|
8
|
+
plans/tasks/waves/roadmap, decisions, lifecycle notes.
|
|
9
|
+
|
|
10
|
+
Search unions both. Always-on injection stays short (USER.md + PROJECTS.md).
|
|
11
|
+
Chat bodies stay in product folders; only titles/paths are ingested.
|
|
12
|
+
`add_memory` requires kind+name (user taxonomy) or project= (in-tree notes).
|
|
13
|
+
AGENTS.md is the instruction file. CLAUDE.md is bound to it (symlink, else hardlink, else copy).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import shutil
|
|
21
|
+
import sys
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Dict, Iterable, List, Optional, Tuple
|
|
25
|
+
|
|
26
|
+
from . import ROOT
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_engine_repo(repo: Path) -> bool:
|
|
30
|
+
"""True for this engine clone — no in-tree ``.agents/`` or ``.cursor/``."""
|
|
31
|
+
try:
|
|
32
|
+
return repo.resolve() == ROOT.resolve()
|
|
33
|
+
except OSError:
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
ABI_DIR = ROOT / "abi"
|
|
38
|
+
ABI_LAYOUT = ABI_DIR / "LAYOUT.md"
|
|
39
|
+
EXAMPLES = ROOT / "examples"
|
|
40
|
+
CLONE_LEAK_DIRS = (ROOT / "memory", ROOT / "examples")
|
|
41
|
+
LEGACY_MEMORY = ROOT / "memory"
|
|
42
|
+
AGENTS_HOME = Path.home() / ".agents"
|
|
43
|
+
AGENTS_RULES = AGENTS_HOME / "rules"
|
|
44
|
+
USER_MEMORY = AGENTS_HOME / "memory"
|
|
45
|
+
MEMORY = USER_MEMORY
|
|
46
|
+
ORPHANS = USER_MEMORY / "orphans"
|
|
47
|
+
USER_MD = USER_MEMORY / "USER.md"
|
|
48
|
+
PROJECTS_MD = USER_MEMORY / "PROJECTS.md"
|
|
49
|
+
SCAN_JSON = USER_MEMORY / "scan.json"
|
|
50
|
+
INGEST_JSON = USER_MEMORY / "ingest.json"
|
|
51
|
+
FACTS_MD = USER_MEMORY / "facts.md"
|
|
52
|
+
CHATS_INDEX = USER_MEMORY / "chats-index.md"
|
|
53
|
+
LAYOUT_MD = USER_MEMORY / "LAYOUT.md"
|
|
54
|
+
PROJECTS_DIR = USER_MEMORY / "projects"
|
|
55
|
+
CLAUDE_HOME = Path.home() / ".claude"
|
|
56
|
+
HOME_AGENTS = AGENTS_HOME / "AGENTS.md"
|
|
57
|
+
HOME_CLAUDE = AGENTS_HOME / "CLAUDE.md"
|
|
58
|
+
|
|
59
|
+
NOTE_COLLECTIONS = (
|
|
60
|
+
"projects",
|
|
61
|
+
"interests",
|
|
62
|
+
"education",
|
|
63
|
+
"finance",
|
|
64
|
+
"family",
|
|
65
|
+
"preferences",
|
|
66
|
+
"programming",
|
|
67
|
+
"work",
|
|
68
|
+
"certifications",
|
|
69
|
+
"scratch",
|
|
70
|
+
)
|
|
71
|
+
PROJECT_ARTIFACTS = {
|
|
72
|
+
"research": "research",
|
|
73
|
+
"plan": "plans",
|
|
74
|
+
"plans": "plans",
|
|
75
|
+
"task": "tasks",
|
|
76
|
+
"tasks": "tasks",
|
|
77
|
+
"roadmap": "roadmap",
|
|
78
|
+
"wave": "waves",
|
|
79
|
+
"waves": "waves",
|
|
80
|
+
"decision": "decisions",
|
|
81
|
+
"decisions": "decisions",
|
|
82
|
+
"adr": "decisions",
|
|
83
|
+
"staging": "staging",
|
|
84
|
+
"captured": "staging",
|
|
85
|
+
}
|
|
86
|
+
SEQUENTIAL_FOLDERS = frozenset({"plans", "tasks", "roadmap", "waves", "decisions"})
|
|
87
|
+
NOTE_LIFECYCLES = ("proposed", "implemented", "rejected")
|
|
88
|
+
NOTE_CLASSES = (
|
|
89
|
+
"feature",
|
|
90
|
+
"bug-fix",
|
|
91
|
+
"simplification",
|
|
92
|
+
"architecture",
|
|
93
|
+
"process",
|
|
94
|
+
"testing",
|
|
95
|
+
)
|
|
96
|
+
# add_memory appends bullets. These kinds should be edited in place when facts change.
|
|
97
|
+
REVISE_IN_PLACE_KINDS = frozenset(
|
|
98
|
+
{"research", "implemented", "decision", "decisions", "adr", "project", "projects"}
|
|
99
|
+
)
|
|
100
|
+
APPEND_INBOX_KINDS = frozenset({"staging", "captured", "scratch"})
|
|
101
|
+
PROJECT_MEMORY_TOP = (
|
|
102
|
+
"staging",
|
|
103
|
+
"research",
|
|
104
|
+
"plans",
|
|
105
|
+
"tasks",
|
|
106
|
+
"roadmap",
|
|
107
|
+
"waves",
|
|
108
|
+
"decisions",
|
|
109
|
+
)
|
|
110
|
+
SEQ_RE = re.compile(r"^(\d{2,4})(?:-|$)")
|
|
111
|
+
POINTER_MARK = "<!-- agent-memory-sync-pointer -->"
|
|
112
|
+
STAGING_HEADER = (
|
|
113
|
+
"# Staging\n\n"
|
|
114
|
+
"Not memory. Distill each bullet into a typed file "
|
|
115
|
+
"(`plans/001-…`, `tasks/001-…`, `waves/001-…`, `roadmap/001-…`, "
|
|
116
|
+
"`decisions/001-…`, `notes/proposed|implemented|rejected/<class>/`), "
|
|
117
|
+
"then delete it here.\n"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
MARKER = "<!-- agent-memory-sync -->"
|
|
121
|
+
PATHS_BEGIN = "<!-- agent-memory-paths -->"
|
|
122
|
+
PATHS_END = "<!-- /agent-memory-paths -->"
|
|
123
|
+
DEFAULT_RULE_NAME = "user-rules.mdc"
|
|
124
|
+
LEGACY_RULE_STEMS = ("felix-always",)
|
|
125
|
+
SKILL_TEMPLATE = ROOT / "skills" / "memory-sync" / "SKILL.md"
|
|
126
|
+
SKIP_SKILL_NAMES = {
|
|
127
|
+
"antigravity_guide",
|
|
128
|
+
"agy-customizations",
|
|
129
|
+
"permissioned-github",
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
INJECTION_GEMINI = Path.home() / ".gemini" / "config" / "AGENTS.md"
|
|
133
|
+
|
|
134
|
+
SKIP_DIR_DEFAULT = {
|
|
135
|
+
".git",
|
|
136
|
+
".agents",
|
|
137
|
+
".cursor",
|
|
138
|
+
".gemini",
|
|
139
|
+
".github",
|
|
140
|
+
".next",
|
|
141
|
+
".svelte-kit",
|
|
142
|
+
".turbo",
|
|
143
|
+
".venv",
|
|
144
|
+
"__pycache__",
|
|
145
|
+
"build",
|
|
146
|
+
"dist",
|
|
147
|
+
"node_modules",
|
|
148
|
+
"venv",
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
ROW_RE = re.compile(
|
|
152
|
+
r"^\|\s*(?P<slug>[^|]+?)\s*\|\s*`?(?P<path>[^|`]+?)`?\s*\|\s*(?P<role>[^|]+?)\s*\|\s*(?P<stack>[^|]+?)\s*\|\s*(?P<status>[^|]+?)\s*\|$"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass
|
|
157
|
+
class Project:
|
|
158
|
+
slug: str
|
|
159
|
+
path: str
|
|
160
|
+
role: str
|
|
161
|
+
stack: str
|
|
162
|
+
status: str = "active"
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def path_obj(self) -> Path:
|
|
166
|
+
return Path(self.path)
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def memory_dir(self) -> Path:
|
|
170
|
+
repo = self.path_obj
|
|
171
|
+
if repo.is_dir():
|
|
172
|
+
return repo / ".agents" / "memory"
|
|
173
|
+
return ORPHANS / self.slug
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def detail_path(self) -> Path:
|
|
177
|
+
return self.memory_dir / "README.md"
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def user_link_dir(self) -> Path:
|
|
181
|
+
return USER_MEMORY / "projects" / self.slug
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def user_link_path(self) -> Path:
|
|
185
|
+
return self.user_link_dir / "README.md"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _read(path: Path) -> str:
|
|
189
|
+
if not path.exists():
|
|
190
|
+
return ""
|
|
191
|
+
return path.read_text(encoding="utf-8")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _write(path: Path, content: str) -> None:
|
|
195
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
if not content.endswith("\n"):
|
|
197
|
+
content += "\n"
|
|
198
|
+
path.write_text(content, encoding="utf-8")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _default_roots() -> List[str]:
|
|
202
|
+
candidates = [
|
|
203
|
+
Path.home() / "Coding",
|
|
204
|
+
Path.home() / "code",
|
|
205
|
+
Path.home() / "Developer",
|
|
206
|
+
Path.home() / "src",
|
|
207
|
+
]
|
|
208
|
+
found = [str(p) for p in candidates if p.is_dir()]
|
|
209
|
+
return found or [str(Path.home())]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def default_scan() -> dict:
|
|
213
|
+
return {
|
|
214
|
+
"roots": _default_roots(),
|
|
215
|
+
"agent_rule_name": DEFAULT_RULE_NAME,
|
|
216
|
+
"ignore_dir_names": sorted(SKIP_DIR_DEFAULT),
|
|
217
|
+
"ignore_slugs": [],
|
|
218
|
+
"expand_children": [],
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def shipped_layout_text() -> str:
|
|
223
|
+
if not ABI_LAYOUT.is_file():
|
|
224
|
+
raise FileNotFoundError(f"Missing shipped layout contract: {ABI_LAYOUT}")
|
|
225
|
+
return _read(ABI_LAYOUT)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _copy_if_missing(src: Path, dst: Path) -> bool:
|
|
229
|
+
if not src.exists() or dst.exists():
|
|
230
|
+
return False
|
|
231
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
232
|
+
shutil.copy2(src, dst)
|
|
233
|
+
return True
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _is_scaffold_file(path: Path) -> bool:
|
|
237
|
+
name = path.name
|
|
238
|
+
return ".example." in name or name.endswith(".example.json") or name == "orphans.example.md"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def consolidate_repo_leaks() -> List[str]:
|
|
242
|
+
"""Move live markdown from the engine clone into ~/.agents/memory; delete clone copies."""
|
|
243
|
+
moved: List[str] = []
|
|
244
|
+
orphans_doc = ORPHANS / "README.md"
|
|
245
|
+
if not orphans_doc.exists() and (EXAMPLES / "orphans.example.md").is_file():
|
|
246
|
+
_write(orphans_doc, _read(EXAMPLES / "orphans.example.md"))
|
|
247
|
+
|
|
248
|
+
def relocate(src: Path, dest: Path) -> None:
|
|
249
|
+
if not src.is_file() or _is_scaffold_file(src):
|
|
250
|
+
return
|
|
251
|
+
if dest.exists() and dest.stat().st_size > 0:
|
|
252
|
+
if src.resolve() != dest.resolve():
|
|
253
|
+
orphan = ORPHANS / src.name
|
|
254
|
+
orphan.parent.mkdir(parents=True, exist_ok=True)
|
|
255
|
+
shutil.copy2(src, orphan)
|
|
256
|
+
moved.append(f"orphan copy {src} -> {orphan}")
|
|
257
|
+
src.unlink()
|
|
258
|
+
moved.append(f"removed clone leak {src}")
|
|
259
|
+
return
|
|
260
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
shutil.move(str(src), str(dest))
|
|
262
|
+
moved.append(str(dest))
|
|
263
|
+
|
|
264
|
+
for clone_root in CLONE_LEAK_DIRS:
|
|
265
|
+
if not clone_root.is_dir():
|
|
266
|
+
continue
|
|
267
|
+
relocate(clone_root / "USER.md", USER_MD)
|
|
268
|
+
relocate(clone_root / "PROJECTS.md", PROJECTS_MD)
|
|
269
|
+
relocate(clone_root / "scan.json", SCAN_JSON)
|
|
270
|
+
relocate(clone_root / "chats-index.md", CHATS_INDEX)
|
|
271
|
+
relocate(clone_root / "ingest.json", INGEST_JSON)
|
|
272
|
+
facts = clone_root / "facts.md"
|
|
273
|
+
if facts.is_file() and not _is_scaffold_file(facts):
|
|
274
|
+
dest = ORPHANS / "facts.md"
|
|
275
|
+
if dest.exists():
|
|
276
|
+
for line in _read(facts).splitlines():
|
|
277
|
+
stripped = line.strip()
|
|
278
|
+
if stripped.startswith("- "):
|
|
279
|
+
_append_bullet(dest, stripped[2:].strip())
|
|
280
|
+
else:
|
|
281
|
+
relocate(facts, dest)
|
|
282
|
+
if facts.exists():
|
|
283
|
+
facts.unlink()
|
|
284
|
+
moved.append(f"removed clone leak {facts}")
|
|
285
|
+
projects = clone_root / "projects"
|
|
286
|
+
if projects.is_dir():
|
|
287
|
+
for src in sorted(projects.glob("*.md")):
|
|
288
|
+
slug = src.stem
|
|
289
|
+
dest = USER_MEMORY / "projects" / slug / "README.md"
|
|
290
|
+
relocate(src, dest)
|
|
291
|
+
for child in sorted(clone_root.rglob("*"), reverse=True):
|
|
292
|
+
if child.is_dir() and not any(child.iterdir()):
|
|
293
|
+
try:
|
|
294
|
+
child.rmdir()
|
|
295
|
+
moved.append(f"removed empty {child}")
|
|
296
|
+
except OSError:
|
|
297
|
+
pass
|
|
298
|
+
migrate_taxonomy()
|
|
299
|
+
moved.extend(purge_engine_repo_injection())
|
|
300
|
+
return moved
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _relocate_engine_staging(captured: Path) -> None:
|
|
304
|
+
if not captured.is_file():
|
|
305
|
+
return
|
|
306
|
+
bullets = [
|
|
307
|
+
ln.strip()[2:].strip()
|
|
308
|
+
for ln in _read(captured).splitlines()
|
|
309
|
+
if ln.strip().startswith("- ")
|
|
310
|
+
]
|
|
311
|
+
if not bullets:
|
|
312
|
+
return
|
|
313
|
+
dest = USER_MEMORY / "staging" / "captured.md"
|
|
314
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
315
|
+
if not dest.exists():
|
|
316
|
+
_write(dest, STAGING_HEADER + "\n")
|
|
317
|
+
for bullet in bullets:
|
|
318
|
+
_append_bullet(dest, bullet)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def purge_engine_repo_injection() -> List[str]:
|
|
322
|
+
"""Remove ``.agents/`` and ``.cursor/`` from the engine clone (never project memory here)."""
|
|
323
|
+
moved: List[str] = []
|
|
324
|
+
captured = ROOT / ".agents" / "memory" / "staging" / "captured.md"
|
|
325
|
+
_relocate_engine_staging(captured)
|
|
326
|
+
for name in (".agents", ".cursor"):
|
|
327
|
+
path = ROOT / name
|
|
328
|
+
if not path.exists():
|
|
329
|
+
continue
|
|
330
|
+
shutil.rmtree(path)
|
|
331
|
+
moved.append(f"removed engine {path}")
|
|
332
|
+
return moved
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def migrate_legacy_store() -> List[str]:
|
|
336
|
+
"""Deprecated alias. Use consolidate_repo_leaks()."""
|
|
337
|
+
return consolidate_repo_leaks()
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _relocate(src: Path, dst: Path) -> bool:
|
|
341
|
+
if not src.exists():
|
|
342
|
+
return False
|
|
343
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
344
|
+
if src.resolve() == dst.resolve():
|
|
345
|
+
return False
|
|
346
|
+
if dst.exists():
|
|
347
|
+
return False
|
|
348
|
+
shutil.move(str(src), str(dst))
|
|
349
|
+
return True
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def ensure_staging_inbox(mem: Path) -> None:
|
|
353
|
+
"""Bootstrap project memory with files only — no empty folder tree."""
|
|
354
|
+
captured = mem / "staging" / "captured.md"
|
|
355
|
+
if not captured.exists():
|
|
356
|
+
_write(captured, STAGING_HEADER + "\n")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _ensure_staging_banner(path: Path) -> None:
|
|
360
|
+
if not path.exists() or path.parent.name != "staging":
|
|
361
|
+
return
|
|
362
|
+
text = _read(path)
|
|
363
|
+
if text.lstrip().startswith("# Staging"):
|
|
364
|
+
return
|
|
365
|
+
_write(path, STAGING_HEADER + "\n" + text.lstrip())
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _merge_relocate(src: Path, dest: Path) -> None:
|
|
369
|
+
if not src.exists():
|
|
370
|
+
return
|
|
371
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
372
|
+
if src.resolve() == dest.resolve():
|
|
373
|
+
_ensure_staging_banner(dest)
|
|
374
|
+
return
|
|
375
|
+
if not dest.exists():
|
|
376
|
+
_relocate(src, dest)
|
|
377
|
+
_ensure_staging_banner(dest)
|
|
378
|
+
return
|
|
379
|
+
for line in _read(src).splitlines():
|
|
380
|
+
stripped = line.strip()
|
|
381
|
+
if stripped.startswith("- "):
|
|
382
|
+
_append_bullet(dest, stripped[2:].strip())
|
|
383
|
+
src.unlink()
|
|
384
|
+
_ensure_staging_banner(dest)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def migrate_taxonomy() -> None:
|
|
388
|
+
"""Folders instead of dump files. Safe to re-run."""
|
|
389
|
+
projects_root = USER_MEMORY / "projects"
|
|
390
|
+
if projects_root.is_dir():
|
|
391
|
+
for src in list(projects_root.glob("*.md")):
|
|
392
|
+
if src.name.lower() == "readme.md":
|
|
393
|
+
continue
|
|
394
|
+
_relocate(src, projects_root / src.stem / "README.md")
|
|
395
|
+
notes_root = USER_MEMORY / "notes"
|
|
396
|
+
known = {p.slug for p in parse_projects()} if PROJECTS_MD.exists() else set()
|
|
397
|
+
if projects_root.is_dir():
|
|
398
|
+
for child in projects_root.iterdir():
|
|
399
|
+
if child.is_dir():
|
|
400
|
+
known.add(child.name)
|
|
401
|
+
collections = set(NOTE_COLLECTIONS)
|
|
402
|
+
if notes_root.is_dir():
|
|
403
|
+
for child in list(notes_root.iterdir()):
|
|
404
|
+
if not child.is_dir() or child.name in collections:
|
|
405
|
+
continue
|
|
406
|
+
if child.name in known:
|
|
407
|
+
_relocate(child, notes_root / "projects" / child.name)
|
|
408
|
+
if child.is_dir() and child.exists() and not any(child.iterdir()):
|
|
409
|
+
child.rmdir()
|
|
410
|
+
if FACTS_MD.exists():
|
|
411
|
+
dest = notes_root / "programming" / "chat-stores.md"
|
|
412
|
+
if _relocate(FACTS_MD, dest) is False and dest.exists():
|
|
413
|
+
FACTS_MD.unlink()
|
|
414
|
+
for p in parse_projects():
|
|
415
|
+
if is_engine_repo(p.path_obj):
|
|
416
|
+
if not p.user_link_path.exists():
|
|
417
|
+
_write(p.user_link_path, stub_project_md(p))
|
|
418
|
+
continue
|
|
419
|
+
mem = p.memory_dir
|
|
420
|
+
if not mem.is_dir():
|
|
421
|
+
continue
|
|
422
|
+
ensure_staging_inbox(mem)
|
|
423
|
+
_merge_relocate(mem / "facts.md", mem / "staging" / "captured.md")
|
|
424
|
+
_merge_relocate(mem / "notes" / "captured.md", mem / "staging" / "captured.md")
|
|
425
|
+
_merge_relocate(mem / "from-chats.md", mem / "staging" / "from-chats.md")
|
|
426
|
+
_merge_relocate(mem / "research" / "from-chats.md", mem / "staging" / "from-chats.md")
|
|
427
|
+
if not p.detail_path.exists():
|
|
428
|
+
_write(p.detail_path, stub_project_md(p))
|
|
429
|
+
if not p.user_link_path.exists():
|
|
430
|
+
_write(p.user_link_path, stub_project_md(p))
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def ensure_memory_layout() -> None:
|
|
434
|
+
"""Create ~/.agents/memory and copy example scaffolding if missing."""
|
|
435
|
+
USER_MEMORY.mkdir(parents=True, exist_ok=True)
|
|
436
|
+
_write(LAYOUT_MD, shipped_layout_text())
|
|
437
|
+
orphans_doc = ORPHANS / "README.md"
|
|
438
|
+
if not orphans_doc.exists() and (EXAMPLES / "orphans.example.md").is_file():
|
|
439
|
+
_write(orphans_doc, _read(EXAMPLES / "orphans.example.md"))
|
|
440
|
+
notes_readme = USER_MEMORY / "notes" / "README.md"
|
|
441
|
+
if not notes_readme.exists():
|
|
442
|
+
_write(
|
|
443
|
+
notes_readme,
|
|
444
|
+
"# Note collections\n\n"
|
|
445
|
+
"Guide folders (not a closed set): `projects/` `interests/` "
|
|
446
|
+
"`education/` `finance/` `family/` `preferences/` `programming/` "
|
|
447
|
+
"`work/` `certifications/` `scratch/`.\n\n"
|
|
448
|
+
"Add a new folder when a fact does not fit. "
|
|
449
|
+
"`notes/projects/<slug>/` is personal notes about a project. "
|
|
450
|
+
"The project **link** is `projects/<slug>/README.md`. "
|
|
451
|
+
"Research, plans, tasks, waves, roadmap, decisions, and lifecycle notes live in "
|
|
452
|
+
"`<repo>/.agents/memory/`. `staging/` there is an inbox, not memory.\n",
|
|
453
|
+
)
|
|
454
|
+
pairs = (
|
|
455
|
+
(EXAMPLES / "USER.example.md", USER_MD),
|
|
456
|
+
(EXAMPLES / "PROJECTS.example.md", PROJECTS_MD),
|
|
457
|
+
(EXAMPLES / "scan.example.json", SCAN_JSON),
|
|
458
|
+
(EXAMPLES / "ingest.example.json", INGEST_JSON),
|
|
459
|
+
)
|
|
460
|
+
for src, dst in pairs:
|
|
461
|
+
if dst.exists() or not src.exists():
|
|
462
|
+
continue
|
|
463
|
+
_write(dst, _read(src))
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def load_scan() -> dict:
|
|
467
|
+
ensure_memory_layout()
|
|
468
|
+
if not SCAN_JSON.exists():
|
|
469
|
+
cfg = default_scan()
|
|
470
|
+
save_scan(cfg)
|
|
471
|
+
return cfg
|
|
472
|
+
cfg = json.loads(_read(SCAN_JSON))
|
|
473
|
+
if not cfg.get("agent_rule_name"):
|
|
474
|
+
cfg["agent_rule_name"] = cfg.get("cursor_rule_name") or DEFAULT_RULE_NAME
|
|
475
|
+
if not cfg.get("roots"):
|
|
476
|
+
cfg["roots"] = _default_roots()
|
|
477
|
+
if "ignore_dir_names" not in cfg:
|
|
478
|
+
cfg["ignore_dir_names"] = sorted(SKIP_DIR_DEFAULT)
|
|
479
|
+
if "ignore_slugs" not in cfg:
|
|
480
|
+
cfg["ignore_slugs"] = []
|
|
481
|
+
if "expand_children" not in cfg:
|
|
482
|
+
cfg["expand_children"] = []
|
|
483
|
+
return cfg
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def agent_rule_name() -> str:
|
|
487
|
+
cfg = load_scan()
|
|
488
|
+
name = str(cfg.get("agent_rule_name") or cfg.get("cursor_rule_name") or DEFAULT_RULE_NAME).strip()
|
|
489
|
+
if not name.endswith(".mdc"):
|
|
490
|
+
name += ".mdc"
|
|
491
|
+
return name
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def canonical_agent_rule() -> Path:
|
|
495
|
+
return AGENTS_RULES / agent_rule_name()
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def injection_agent_rule() -> Path:
|
|
499
|
+
"""Canonical always-on rule path (provider-agnostic)."""
|
|
500
|
+
return canonical_agent_rule()
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
HOST_RULE_DIRS = (
|
|
504
|
+
Path.home() / ".cursor" / "rules",
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def bind_host_rules(canonical: Path) -> Tuple[List[str], List[str]]:
|
|
509
|
+
"""Bind every ``~/.agents/rules/*.mdc`` into host rule slots (e.g. ``~/.cursor/rules/``)."""
|
|
510
|
+
written: List[str] = []
|
|
511
|
+
warnings: List[str] = []
|
|
512
|
+
AGENTS_RULES.mkdir(parents=True, exist_ok=True)
|
|
513
|
+
sources = sorted(AGENTS_RULES.glob("*.mdc"), key=lambda p: p.name.lower())
|
|
514
|
+
if canonical.exists() and canonical not in sources:
|
|
515
|
+
sources.insert(0, canonical)
|
|
516
|
+
for rules_dir in HOST_RULE_DIRS:
|
|
517
|
+
rules_dir.mkdir(parents=True, exist_ok=True)
|
|
518
|
+
for src in sources:
|
|
519
|
+
dest = rules_dir / src.name
|
|
520
|
+
if dest.resolve() == src.resolve():
|
|
521
|
+
continue
|
|
522
|
+
path, method = bind_to(dest, src)
|
|
523
|
+
_bind_collect(path, method, written, warnings)
|
|
524
|
+
return written, warnings
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def profile_title() -> str:
|
|
528
|
+
for line in _read(USER_MD).splitlines():
|
|
529
|
+
if line.startswith("# "):
|
|
530
|
+
return line[2:].strip()
|
|
531
|
+
return "agent memory"
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def scan_roots() -> List[str]:
|
|
535
|
+
return [str(Path(r).expanduser()) for r in (load_scan().get("roots") or [])]
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def mcp_entry() -> dict:
|
|
539
|
+
entry: dict = {
|
|
540
|
+
"command": sys.executable,
|
|
541
|
+
"args": ["-m", "agent_memory.mcp_server"],
|
|
542
|
+
}
|
|
543
|
+
src_dir = ROOT / "src"
|
|
544
|
+
if src_dir.is_dir():
|
|
545
|
+
entry["env"] = {"PYTHONPATH": str(src_dir.resolve())}
|
|
546
|
+
return entry
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def mcp_snippet() -> str:
|
|
550
|
+
return json.dumps({"agent-memory": mcp_entry()}, indent=2)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def cursor_mcp_path() -> Path:
|
|
554
|
+
return Path.home() / ".cursor" / "mcp.json"
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def claude_desktop_config_path() -> Path:
|
|
558
|
+
if os.name == "nt":
|
|
559
|
+
appdata = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
|
|
560
|
+
return Path(appdata) / "Claude" / "claude_desktop_config.json"
|
|
561
|
+
if sys.platform == "darwin":
|
|
562
|
+
return (
|
|
563
|
+
Path.home()
|
|
564
|
+
/ "Library"
|
|
565
|
+
/ "Application Support"
|
|
566
|
+
/ "Claude"
|
|
567
|
+
/ "claude_desktop_config.json"
|
|
568
|
+
)
|
|
569
|
+
return Path.home() / ".config" / "Claude" / "claude_desktop_config.json"
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def known_host_mcp_paths() -> List[Path]:
|
|
573
|
+
"""All known agent and IDE MCP configuration file paths across operating systems."""
|
|
574
|
+
home = Path.home()
|
|
575
|
+
paths: List[Path] = [
|
|
576
|
+
# Cursor
|
|
577
|
+
home / ".cursor" / "mcp.json",
|
|
578
|
+
# Windsurf
|
|
579
|
+
home / ".codeium" / "windsurf" / "mcp_config.json",
|
|
580
|
+
# Claude Desktop
|
|
581
|
+
claude_desktop_config_path(),
|
|
582
|
+
# Antigravity / Gemini CLI
|
|
583
|
+
home / ".gemini" / "antigravity-ide" / "mcp_config.json",
|
|
584
|
+
home / ".gemini" / "config" / "mcp_config.json",
|
|
585
|
+
home / ".gemini" / "config" / "mcp.json",
|
|
586
|
+
# OpenAI / Codex
|
|
587
|
+
home / ".codex" / "mcp.json",
|
|
588
|
+
home / ".codex" / "config.json",
|
|
589
|
+
home / ".openai" / "mcp.json",
|
|
590
|
+
]
|
|
591
|
+
|
|
592
|
+
# Roo Code & Cline (VS Code, VS Code Insiders, VSCodium)
|
|
593
|
+
vscode_roots: List[Path] = []
|
|
594
|
+
if os.name == "nt":
|
|
595
|
+
appdata = os.environ.get("APPDATA") or str(home / "AppData" / "Roaming")
|
|
596
|
+
vscode_roots.extend(
|
|
597
|
+
[
|
|
598
|
+
Path(appdata) / "Code" / "User",
|
|
599
|
+
Path(appdata) / "Code - Insiders" / "User",
|
|
600
|
+
Path(appdata) / "VSCodium" / "User",
|
|
601
|
+
]
|
|
602
|
+
)
|
|
603
|
+
elif sys.platform == "darwin":
|
|
604
|
+
vscode_roots.extend(
|
|
605
|
+
[
|
|
606
|
+
home / "Library" / "Application Support" / "Code" / "User",
|
|
607
|
+
home / "Library" / "Application Support" / "Code - Insiders" / "User",
|
|
608
|
+
home / "Library" / "Application Support" / "VSCodium" / "User",
|
|
609
|
+
]
|
|
610
|
+
)
|
|
611
|
+
else:
|
|
612
|
+
vscode_roots.extend(
|
|
613
|
+
[
|
|
614
|
+
home / ".config" / "Code" / "User",
|
|
615
|
+
home / ".config" / "Code - Insiders" / "User",
|
|
616
|
+
home / ".config" / "VSCodium" / "User",
|
|
617
|
+
]
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
for vroot in vscode_roots:
|
|
621
|
+
paths.extend(
|
|
622
|
+
[
|
|
623
|
+
vroot
|
|
624
|
+
/ "globalStorage"
|
|
625
|
+
/ "rooveterinaryinc.roo-cline"
|
|
626
|
+
/ "settings"
|
|
627
|
+
/ "cline_mcp_settings.json",
|
|
628
|
+
vroot
|
|
629
|
+
/ "globalStorage"
|
|
630
|
+
/ "saoudrizwan.claude-dev"
|
|
631
|
+
/ "settings"
|
|
632
|
+
/ "cline_mcp_settings.json",
|
|
633
|
+
vroot
|
|
634
|
+
/ "globalStorage"
|
|
635
|
+
/ "claude-dev"
|
|
636
|
+
/ "settings"
|
|
637
|
+
/ "cline_mcp_settings.json",
|
|
638
|
+
]
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
return paths
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def user_profile_looks_blank() -> bool:
|
|
645
|
+
return bool(re.search(r"^- Name:\s*$", _read(USER_MD), re.M))
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _merge_mcp_server_into_file(path: Path) -> str:
|
|
649
|
+
if not path.parent.exists():
|
|
650
|
+
return ""
|
|
651
|
+
if path.exists():
|
|
652
|
+
raw = _read(path)
|
|
653
|
+
try:
|
|
654
|
+
data = json.loads(raw) if raw.strip() else {}
|
|
655
|
+
except json.JSONDecodeError as e:
|
|
656
|
+
return f"FAIL {path}: invalid JSON ({e})."
|
|
657
|
+
if not isinstance(data, dict):
|
|
658
|
+
return f"FAIL {path}: root is not an object."
|
|
659
|
+
else:
|
|
660
|
+
data = {}
|
|
661
|
+
servers = data.setdefault("mcpServers", {})
|
|
662
|
+
if not isinstance(servers, dict):
|
|
663
|
+
return f"FAIL {path}: mcpServers is not an object."
|
|
664
|
+
servers["agent-memory"] = mcp_entry()
|
|
665
|
+
_write(path, json.dumps(data, indent=2, ensure_ascii=False))
|
|
666
|
+
return f"OK {path}"
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def merge_agent_mcp() -> str:
|
|
670
|
+
"""Insert/update the agent-memory server in all installed host MCP configs."""
|
|
671
|
+
cursor_p = cursor_mcp_path()
|
|
672
|
+
cursor_p.parent.mkdir(parents=True, exist_ok=True)
|
|
673
|
+
targets = known_host_mcp_paths()
|
|
674
|
+
results: List[str] = []
|
|
675
|
+
seen: set[str] = set()
|
|
676
|
+
for target in targets:
|
|
677
|
+
key = str(target.resolve()).lower()
|
|
678
|
+
if key in seen:
|
|
679
|
+
continue
|
|
680
|
+
seen.add(key)
|
|
681
|
+
if target.parent.exists() or target == cursor_p:
|
|
682
|
+
res = _merge_mcp_server_into_file(target)
|
|
683
|
+
if res:
|
|
684
|
+
results.append(res)
|
|
685
|
+
return "; ".join(results) if results else f"OK {cursor_p}"
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def zed_config_dir() -> Path:
|
|
689
|
+
if os.name == "nt":
|
|
690
|
+
appdata = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
|
|
691
|
+
return Path(appdata) / "Zed"
|
|
692
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
693
|
+
if xdg:
|
|
694
|
+
return Path(xdg) / "zed"
|
|
695
|
+
return Path.home() / ".config" / "zed"
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def zed_settings_path() -> Path:
|
|
699
|
+
return zed_config_dir() / "settings.json"
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def zed_agents_path() -> Path:
|
|
703
|
+
return zed_config_dir() / "AGENTS.md"
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _strip_jsonc(text: str) -> str:
|
|
707
|
+
out: List[str] = []
|
|
708
|
+
i = 0
|
|
709
|
+
n = len(text)
|
|
710
|
+
in_str = False
|
|
711
|
+
escape = False
|
|
712
|
+
while i < n:
|
|
713
|
+
ch = text[i]
|
|
714
|
+
nxt = text[i + 1] if i + 1 < n else ""
|
|
715
|
+
if in_str:
|
|
716
|
+
out.append(ch)
|
|
717
|
+
if escape:
|
|
718
|
+
escape = False
|
|
719
|
+
elif ch == "\\":
|
|
720
|
+
escape = True
|
|
721
|
+
elif ch == '"':
|
|
722
|
+
in_str = False
|
|
723
|
+
i += 1
|
|
724
|
+
continue
|
|
725
|
+
if ch == '"':
|
|
726
|
+
in_str = True
|
|
727
|
+
out.append(ch)
|
|
728
|
+
i += 1
|
|
729
|
+
continue
|
|
730
|
+
if ch == "/" and nxt == "/":
|
|
731
|
+
while i < n and text[i] not in "\r\n":
|
|
732
|
+
i += 1
|
|
733
|
+
continue
|
|
734
|
+
if ch == "/" and nxt == "*":
|
|
735
|
+
i += 2
|
|
736
|
+
while i < n and not (text[i] == "*" and i + 1 < n and text[i + 1] == "/"):
|
|
737
|
+
i += 1
|
|
738
|
+
i = min(i + 2, n)
|
|
739
|
+
continue
|
|
740
|
+
out.append(ch)
|
|
741
|
+
i += 1
|
|
742
|
+
stripped = "".join(out)
|
|
743
|
+
stripped = re.sub(r",(\s*[}\]])", r"\1", stripped)
|
|
744
|
+
return stripped
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _load_jsonc(path: Path) -> dict:
|
|
748
|
+
raw = _read(path)
|
|
749
|
+
if not raw.strip():
|
|
750
|
+
return {}
|
|
751
|
+
data = json.loads(_strip_jsonc(raw))
|
|
752
|
+
if not isinstance(data, dict):
|
|
753
|
+
raise ValueError(f"{path}: root is not an object")
|
|
754
|
+
return data
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _resolve_cmd(cmd: str) -> str:
|
|
758
|
+
if not cmd:
|
|
759
|
+
return cmd
|
|
760
|
+
p = Path(cmd)
|
|
761
|
+
if p.is_file():
|
|
762
|
+
return str(p)
|
|
763
|
+
found = shutil.which(cmd)
|
|
764
|
+
return found or cmd
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _mcp_servers_from_file(path: Path) -> Dict[str, dict]:
|
|
768
|
+
if not path.exists():
|
|
769
|
+
return {}
|
|
770
|
+
try:
|
|
771
|
+
data = json.loads(_read(path) or "{}")
|
|
772
|
+
except json.JSONDecodeError:
|
|
773
|
+
return {}
|
|
774
|
+
if not isinstance(data, dict):
|
|
775
|
+
return {}
|
|
776
|
+
servers = data.get("mcpServers") or data.get("context_servers") or {}
|
|
777
|
+
if not isinstance(servers, dict):
|
|
778
|
+
return {}
|
|
779
|
+
out: Dict[str, dict] = {}
|
|
780
|
+
for name, spec in servers.items():
|
|
781
|
+
if isinstance(name, str) and isinstance(spec, dict):
|
|
782
|
+
out[name] = spec
|
|
783
|
+
return out
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def collect_mcp_servers() -> Dict[str, dict]:
|
|
787
|
+
"""Union of your Agent host MCP configs. Later files fill missing fields only."""
|
|
788
|
+
sources = known_host_mcp_paths()
|
|
789
|
+
merged: Dict[str, dict] = {}
|
|
790
|
+
seen: set[str] = set()
|
|
791
|
+
for path in sources:
|
|
792
|
+
key = str(path.resolve()).lower()
|
|
793
|
+
if key in seen:
|
|
794
|
+
continue
|
|
795
|
+
seen.add(key)
|
|
796
|
+
for name, spec in _mcp_servers_from_file(path).items():
|
|
797
|
+
if name not in merged:
|
|
798
|
+
merged[name] = dict(spec)
|
|
799
|
+
continue
|
|
800
|
+
for k, val in spec.items():
|
|
801
|
+
if k not in merged[name] or merged[name][k] in (None, "", {}, []):
|
|
802
|
+
merged[name][k] = val
|
|
803
|
+
merged["agent-memory"] = mcp_entry()
|
|
804
|
+
return merged
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def mcp_spec_to_zed(spec: dict) -> dict:
|
|
808
|
+
url = spec.get("url")
|
|
809
|
+
if isinstance(url, str) and url.strip():
|
|
810
|
+
entry: dict = {"source": "custom", "url": url.strip()}
|
|
811
|
+
headers = spec.get("headers")
|
|
812
|
+
if isinstance(headers, dict) and headers:
|
|
813
|
+
entry["headers"] = headers
|
|
814
|
+
return entry
|
|
815
|
+
cmd = spec.get("command")
|
|
816
|
+
if isinstance(cmd, dict):
|
|
817
|
+
path = str(cmd.get("path") or cmd.get("command") or "")
|
|
818
|
+
args = cmd.get("args") or []
|
|
819
|
+
env = cmd.get("env") or {}
|
|
820
|
+
entry = {
|
|
821
|
+
"source": "custom",
|
|
822
|
+
"command": _resolve_cmd(path),
|
|
823
|
+
"args": list(args) if isinstance(args, list) else [],
|
|
824
|
+
}
|
|
825
|
+
if isinstance(env, dict) and env:
|
|
826
|
+
entry["env"] = env
|
|
827
|
+
return entry
|
|
828
|
+
command = _resolve_cmd(str(cmd or ""))
|
|
829
|
+
args = spec.get("args") or []
|
|
830
|
+
env = spec.get("env") or {}
|
|
831
|
+
entry = {
|
|
832
|
+
"source": "custom",
|
|
833
|
+
"command": command,
|
|
834
|
+
"args": list(args) if isinstance(args, list) else [],
|
|
835
|
+
}
|
|
836
|
+
if isinstance(env, dict) and env:
|
|
837
|
+
entry["env"] = env
|
|
838
|
+
return entry
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def merge_zed_mcp() -> str:
|
|
842
|
+
"""Upsert merged MCP servers into Zed settings.json context_servers."""
|
|
843
|
+
path = zed_settings_path()
|
|
844
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
845
|
+
if path.exists():
|
|
846
|
+
try:
|
|
847
|
+
data = _load_jsonc(path)
|
|
848
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
849
|
+
return f"FAIL {path}: {e}. Merge skipped."
|
|
850
|
+
else:
|
|
851
|
+
data = {}
|
|
852
|
+
servers = data.get("context_servers")
|
|
853
|
+
if not isinstance(servers, dict):
|
|
854
|
+
servers = {}
|
|
855
|
+
data["context_servers"] = servers
|
|
856
|
+
for name, spec in collect_mcp_servers().items():
|
|
857
|
+
servers[name] = mcp_spec_to_zed(spec)
|
|
858
|
+
agent = data.get("agent")
|
|
859
|
+
if not isinstance(agent, dict):
|
|
860
|
+
agent = {}
|
|
861
|
+
data["agent"] = agent
|
|
862
|
+
perms = agent.get("tool_permissions")
|
|
863
|
+
if not isinstance(perms, dict):
|
|
864
|
+
perms = {}
|
|
865
|
+
agent["tool_permissions"] = perms
|
|
866
|
+
perms.setdefault("default", "allow")
|
|
867
|
+
_write(path, json.dumps(data, indent=2, ensure_ascii=False))
|
|
868
|
+
return f"OK {path} ({len(servers)} context_servers)"
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def skill_source_roots() -> List[Path]:
|
|
872
|
+
home = Path.home()
|
|
873
|
+
return [
|
|
874
|
+
home / ".cursor" / "skills",
|
|
875
|
+
home / ".gemini" / "config" / "skills",
|
|
876
|
+
home / ".claude" / "skills",
|
|
877
|
+
]
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def zed_skills_root() -> Path:
|
|
881
|
+
return Path.home() / ".agents" / "skills"
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def mirror_skills_to_zed() -> List[str]:
|
|
885
|
+
"""Copy user skills from agent host folders into ~/.agents/skills (Zed global)."""
|
|
886
|
+
dest_root = zed_skills_root()
|
|
887
|
+
dest_root.mkdir(parents=True, exist_ok=True)
|
|
888
|
+
written: List[str] = []
|
|
889
|
+
for src_root in skill_source_roots():
|
|
890
|
+
if not src_root.is_dir():
|
|
891
|
+
continue
|
|
892
|
+
for child in sorted(src_root.iterdir()):
|
|
893
|
+
if not child.is_dir() or child.name in SKIP_SKILL_NAMES:
|
|
894
|
+
continue
|
|
895
|
+
skill_md = child / "SKILL.md"
|
|
896
|
+
if not skill_md.is_file():
|
|
897
|
+
continue
|
|
898
|
+
dest = dest_root / child.name
|
|
899
|
+
if dest.resolve() == child.resolve():
|
|
900
|
+
continue
|
|
901
|
+
if dest.exists():
|
|
902
|
+
continue
|
|
903
|
+
shutil.copytree(child, dest)
|
|
904
|
+
written.append(str(dest / "SKILL.md"))
|
|
905
|
+
return written
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def save_scan(cfg: dict) -> None:
|
|
909
|
+
_write(SCAN_JSON, json.dumps(cfg, indent=2, ensure_ascii=False) + "\n")
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def parse_projects(md: Optional[str] = None) -> List[Project]:
|
|
913
|
+
text = md if md is not None else _read(PROJECTS_MD)
|
|
914
|
+
out: List[Project] = []
|
|
915
|
+
for line in text.splitlines():
|
|
916
|
+
m = ROW_RE.match(line.strip())
|
|
917
|
+
if not m:
|
|
918
|
+
continue
|
|
919
|
+
slug = m.group("slug").strip()
|
|
920
|
+
if slug in {"slug", "------"} or slug.startswith("-"):
|
|
921
|
+
continue
|
|
922
|
+
out.append(
|
|
923
|
+
Project(
|
|
924
|
+
slug=slug,
|
|
925
|
+
path=m.group("path").strip(),
|
|
926
|
+
role=m.group("role").strip(),
|
|
927
|
+
stack=m.group("stack").strip(),
|
|
928
|
+
status=m.group("status").strip(),
|
|
929
|
+
)
|
|
930
|
+
)
|
|
931
|
+
return out
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def projects_by_slug() -> Dict[str, Project]:
|
|
935
|
+
return {p.slug: p for p in parse_projects()}
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def render_projects_table(projects: Iterable[Project]) -> str:
|
|
939
|
+
rows = [
|
|
940
|
+
"# Projects",
|
|
941
|
+
"",
|
|
942
|
+
"Canonical map. Change via `python -m agent_memory inventory`, MCP `register_project`, or skill `memory-sync`.",
|
|
943
|
+
"",
|
|
944
|
+
"| slug | path | role | stack | status |",
|
|
945
|
+
"|------|------|------|-------|--------|",
|
|
946
|
+
]
|
|
947
|
+
for p in sorted(projects, key=lambda x: x.slug.lower()):
|
|
948
|
+
rows.append(
|
|
949
|
+
f"| {p.slug} | `{p.path}` | {p.role} | {p.stack} | {p.status} |"
|
|
950
|
+
)
|
|
951
|
+
return "\n".join(rows) + "\n"
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def write_projects(projects: List[Project]) -> None:
|
|
955
|
+
_write(PROJECTS_MD, render_projects_table(projects))
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _looks_like_project(path: Path) -> bool:
|
|
959
|
+
if not path.is_dir():
|
|
960
|
+
return False
|
|
961
|
+
markers = (
|
|
962
|
+
".git",
|
|
963
|
+
"package.json",
|
|
964
|
+
"pyproject.toml",
|
|
965
|
+
"Cargo.toml",
|
|
966
|
+
"AGENTS.md",
|
|
967
|
+
"README.md",
|
|
968
|
+
"src",
|
|
969
|
+
"src-tauri",
|
|
970
|
+
)
|
|
971
|
+
return any((path / m).exists() for m in markers)
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def discover_disk() -> List[Tuple[str, Path]]:
|
|
975
|
+
cfg = load_scan()
|
|
976
|
+
ignore_names = set(cfg.get("ignore_dir_names") or []) | SKIP_DIR_DEFAULT
|
|
977
|
+
ignore_slugs = set(cfg.get("ignore_slugs") or [])
|
|
978
|
+
expand = set(cfg.get("expand_children") or [])
|
|
979
|
+
found: List[Tuple[str, Path]] = []
|
|
980
|
+
seen_paths = set()
|
|
981
|
+
|
|
982
|
+
def add(slug: str, path: Path) -> None:
|
|
983
|
+
if slug in ignore_slugs:
|
|
984
|
+
return
|
|
985
|
+
resolved = path.resolve()
|
|
986
|
+
key = str(resolved).lower()
|
|
987
|
+
if key in seen_paths:
|
|
988
|
+
return
|
|
989
|
+
seen_paths.add(key)
|
|
990
|
+
found.append((slug, resolved))
|
|
991
|
+
|
|
992
|
+
for root in cfg.get("roots") or []:
|
|
993
|
+
root_path = Path(root).expanduser()
|
|
994
|
+
if not root_path.is_dir():
|
|
995
|
+
continue
|
|
996
|
+
for child in sorted(root_path.iterdir()):
|
|
997
|
+
if not child.is_dir() or child.name in ignore_names:
|
|
998
|
+
continue
|
|
999
|
+
if child.name.startswith("."):
|
|
1000
|
+
continue
|
|
1001
|
+
add(child.name, child)
|
|
1002
|
+
if child.name in expand:
|
|
1003
|
+
for nested in sorted(child.iterdir()):
|
|
1004
|
+
if not nested.is_dir() or nested.name in ignore_names:
|
|
1005
|
+
continue
|
|
1006
|
+
if nested.name.startswith("."):
|
|
1007
|
+
continue
|
|
1008
|
+
if _looks_like_project(nested):
|
|
1009
|
+
add(nested.name, nested)
|
|
1010
|
+
return found
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def inventory_report() -> dict:
|
|
1014
|
+
tracked = parse_projects()
|
|
1015
|
+
by_slug = {p.slug: p for p in tracked}
|
|
1016
|
+
by_path = {str(p.path_obj.resolve()).lower(): p for p in tracked if p.path_obj.exists()}
|
|
1017
|
+
disk = discover_disk()
|
|
1018
|
+
unknown = []
|
|
1019
|
+
known = []
|
|
1020
|
+
for slug, path in disk:
|
|
1021
|
+
key = str(path).lower()
|
|
1022
|
+
if slug in by_slug or key in by_path:
|
|
1023
|
+
known.append({"slug": slug, "path": str(path), "status": "tracked"})
|
|
1024
|
+
else:
|
|
1025
|
+
unknown.append({"slug": slug, "path": str(path)})
|
|
1026
|
+
missing = []
|
|
1027
|
+
for p in tracked:
|
|
1028
|
+
if not p.path_obj.exists():
|
|
1029
|
+
missing.append({"slug": p.slug, "path": p.path})
|
|
1030
|
+
cfg = load_scan()
|
|
1031
|
+
return {
|
|
1032
|
+
"tracked": [p.__dict__ for p in tracked],
|
|
1033
|
+
"unknown": unknown,
|
|
1034
|
+
"missing": missing,
|
|
1035
|
+
"ignored": cfg.get("ignore_slugs") or [],
|
|
1036
|
+
"known_on_disk": known,
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def stub_project_md(p: Project) -> str:
|
|
1041
|
+
return (
|
|
1042
|
+
f"---\n"
|
|
1043
|
+
f"slug: {p.slug}\n"
|
|
1044
|
+
f"path: {p.path}\n"
|
|
1045
|
+
f"role: {p.role}\n"
|
|
1046
|
+
f"stack: {p.stack}\n"
|
|
1047
|
+
f"status: {p.status}\n"
|
|
1048
|
+
f"---\n\n"
|
|
1049
|
+
f"# {p.slug}\n\n"
|
|
1050
|
+
f"{p.role} · `{p.stack}` · {p.status}\n\n"
|
|
1051
|
+
f"Path: `{p.path}`. MCP `get_project_memories(\"{p.slug}\")` for in-tree memory.\n"
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def ensure_project_file(p: Project, overwrite_empty: bool = False) -> None:
|
|
1056
|
+
if is_engine_repo(p.path_obj):
|
|
1057
|
+
p.user_link_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1058
|
+
if overwrite_empty or not p.user_link_path.exists():
|
|
1059
|
+
_write(p.user_link_path, stub_project_md(p))
|
|
1060
|
+
return
|
|
1061
|
+
dest = p.detail_path
|
|
1062
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
1063
|
+
if not is_engine_repo(p.path_obj):
|
|
1064
|
+
ensure_staging_inbox(p.memory_dir)
|
|
1065
|
+
if p.path_obj.is_dir():
|
|
1066
|
+
gi = dest.parent / ".gitignore"
|
|
1067
|
+
if not gi.exists():
|
|
1068
|
+
_write(gi, "*\n!.gitignore\n")
|
|
1069
|
+
if overwrite_empty or not dest.exists():
|
|
1070
|
+
_write(dest, stub_project_md(p))
|
|
1071
|
+
if overwrite_empty or not p.user_link_path.exists():
|
|
1072
|
+
_write(p.user_link_path, stub_project_md(p))
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def register_project(
|
|
1076
|
+
slug: str,
|
|
1077
|
+
path: str,
|
|
1078
|
+
role: str = "unclassified",
|
|
1079
|
+
stack: str = "—",
|
|
1080
|
+
status: str = "active",
|
|
1081
|
+
) -> Project:
|
|
1082
|
+
slug = slug.strip()
|
|
1083
|
+
path = str(Path(path).expanduser().resolve())
|
|
1084
|
+
projects = parse_projects()
|
|
1085
|
+
existing = {p.slug: p for p in projects}
|
|
1086
|
+
p = Project(slug=slug, path=path, role=role, stack=stack, status=status)
|
|
1087
|
+
existing[slug] = p
|
|
1088
|
+
write_projects(list(existing.values()))
|
|
1089
|
+
ensure_project_file(p)
|
|
1090
|
+
return p
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def ignore_slug(slug: str) -> None:
|
|
1094
|
+
cfg = load_scan()
|
|
1095
|
+
ignored = list(cfg.get("ignore_slugs") or [])
|
|
1096
|
+
if slug not in ignored:
|
|
1097
|
+
ignored.append(slug)
|
|
1098
|
+
cfg["ignore_slugs"] = sorted(ignored)
|
|
1099
|
+
save_scan(cfg)
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def is_compact_always_on() -> bool:
|
|
1103
|
+
cfg = load_scan()
|
|
1104
|
+
return bool(cfg.get("compact_always_on", True))
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def compact_projects_text(projects: List[Project]) -> str:
|
|
1108
|
+
lines = [
|
|
1109
|
+
"# Projects (Compact)",
|
|
1110
|
+
"",
|
|
1111
|
+
"Use MCP `get_project_memories(project=slug)` or `search_memory` for details.",
|
|
1112
|
+
"",
|
|
1113
|
+
"| slug | role | stack | status |",
|
|
1114
|
+
"| --- | --- | --- | --- |",
|
|
1115
|
+
]
|
|
1116
|
+
for p in projects:
|
|
1117
|
+
lines.append(f"| {p.slug} | {p.role} | {p.stack} | {p.status} |")
|
|
1118
|
+
return "\n".join(lines) + "\n"
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def always_on_body() -> str:
|
|
1122
|
+
user = _read(USER_MD).strip()
|
|
1123
|
+
if is_compact_always_on():
|
|
1124
|
+
projects = compact_projects_text(parse_projects()).strip()
|
|
1125
|
+
else:
|
|
1126
|
+
projects = _read(PROJECTS_MD).strip()
|
|
1127
|
+
return f"{user}\n\n---\n\n{projects}\n"
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
def agent_rule_text() -> str:
|
|
1131
|
+
return (
|
|
1132
|
+
"---\n"
|
|
1133
|
+
f"description: {profile_title()}. Always apply.\n"
|
|
1134
|
+
"alwaysApply: true\n"
|
|
1135
|
+
"---\n\n"
|
|
1136
|
+
f"{MARKER}\n\n"
|
|
1137
|
+
f"{always_on_body()}"
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def gemini_agents_text() -> str:
|
|
1142
|
+
return f"{MARKER}\n\n{always_on_body()}"
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
def project_agents_text(p: Project) -> str:
|
|
1146
|
+
return (
|
|
1147
|
+
f"{MARKER}\n\n"
|
|
1148
|
+
f"# Project: {p.slug}\n\n"
|
|
1149
|
+
f"**Path:** `{p.path}` \n"
|
|
1150
|
+
f"**Role:** {p.role} \n"
|
|
1151
|
+
f"**Stack:** {p.stack}\n\n"
|
|
1152
|
+
f"In-tree memory: `.agents/memory/` (staging inbox — distill, do not hoard). "
|
|
1153
|
+
f"Global profile: `~/.agents/AGENTS.md`. "
|
|
1154
|
+
f"MCP `search_memory` / `get_project_memories(\"{p.slug}\")` for detail.\n"
|
|
1155
|
+
)
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def _should_overwrite_agents(path: Path) -> bool:
|
|
1159
|
+
if not path.exists():
|
|
1160
|
+
return True
|
|
1161
|
+
text = _read(path)
|
|
1162
|
+
if MARKER in text:
|
|
1163
|
+
return True
|
|
1164
|
+
if "mem0" in text.lower() or "Mem0" in text:
|
|
1165
|
+
return True
|
|
1166
|
+
stripped = text.lstrip()
|
|
1167
|
+
if stripped.startswith("# Globales User-Profil"):
|
|
1168
|
+
return True
|
|
1169
|
+
return False
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def purge_legacy_rules(rules_dir: Path) -> List[str]:
|
|
1173
|
+
"""Delete old rule filenames so alwaysApply does not double-load."""
|
|
1174
|
+
if not rules_dir.is_dir():
|
|
1175
|
+
return []
|
|
1176
|
+
current = agent_rule_name().lower()
|
|
1177
|
+
removed: List[str] = []
|
|
1178
|
+
for path in list(rules_dir.iterdir()):
|
|
1179
|
+
if not path.is_file():
|
|
1180
|
+
continue
|
|
1181
|
+
name = path.name.lower()
|
|
1182
|
+
if name == current:
|
|
1183
|
+
continue
|
|
1184
|
+
stem = path.stem.lower()
|
|
1185
|
+
drop = stem in LEGACY_RULE_STEMS or name.startswith("felix-always.")
|
|
1186
|
+
if not drop and path.suffix.lower() in {".mdc", ".md", ".mdr"}:
|
|
1187
|
+
if MARKER in _read(path):
|
|
1188
|
+
drop = True
|
|
1189
|
+
if drop:
|
|
1190
|
+
path.unlink()
|
|
1191
|
+
removed.append(str(path))
|
|
1192
|
+
return removed
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def iter_rules_dirs() -> List[Path]:
|
|
1196
|
+
seen = set()
|
|
1197
|
+
out: List[Path] = []
|
|
1198
|
+
|
|
1199
|
+
def add(path: Path) -> None:
|
|
1200
|
+
key = str(path).lower()
|
|
1201
|
+
if key in seen:
|
|
1202
|
+
return
|
|
1203
|
+
seen.add(key)
|
|
1204
|
+
out.append(path)
|
|
1205
|
+
|
|
1206
|
+
add(AGENTS_RULES)
|
|
1207
|
+
for rules_dir in HOST_RULE_DIRS:
|
|
1208
|
+
add(rules_dir)
|
|
1209
|
+
return out
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
def purge_legacy_rules_everywhere() -> List[str]:
|
|
1213
|
+
removed: List[str] = []
|
|
1214
|
+
for rules_dir in iter_rules_dirs():
|
|
1215
|
+
removed.extend(purge_legacy_rules(rules_dir))
|
|
1216
|
+
return removed
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
def _is_git_symlink_stub(path: Path) -> bool:
|
|
1220
|
+
"""True when a Windows checkout turned a git symlink into a 9-byte path file."""
|
|
1221
|
+
try:
|
|
1222
|
+
if path.is_symlink():
|
|
1223
|
+
return False
|
|
1224
|
+
except OSError:
|
|
1225
|
+
pass
|
|
1226
|
+
if not path.exists() or path.stat().st_size > 64:
|
|
1227
|
+
return False
|
|
1228
|
+
return _read(path).strip() in {"AGENTS.md", "CLAUDE.md"}
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
def _is_foreign_instruction_file(path: Path) -> bool:
|
|
1232
|
+
"""True if the file exists and is not ours to overwrite."""
|
|
1233
|
+
if not path.exists() and not path.is_symlink():
|
|
1234
|
+
return False
|
|
1235
|
+
if _is_git_symlink_stub(path):
|
|
1236
|
+
return False
|
|
1237
|
+
if MARKER in _read(path) or _should_overwrite_agents(path):
|
|
1238
|
+
return False
|
|
1239
|
+
return True
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
def _ensure_claude_pointer(path: Path) -> None:
|
|
1243
|
+
text = _read(path)
|
|
1244
|
+
if POINTER_MARK in text:
|
|
1245
|
+
return
|
|
1246
|
+
extra = (
|
|
1247
|
+
f"\n\n{POINTER_MARK}\n"
|
|
1248
|
+
"Also read `AGENTS.md` in this folder (bound to `~/.agents/AGENTS.md`) "
|
|
1249
|
+
"for user memory.\n"
|
|
1250
|
+
)
|
|
1251
|
+
path.write_text(text.rstrip() + extra + ("\n" if not extra.endswith("\n") else ""), encoding="utf-8")
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
def _bound_to(link: Path, target: Path) -> bool:
|
|
1255
|
+
if not target.exists():
|
|
1256
|
+
return False
|
|
1257
|
+
try:
|
|
1258
|
+
if link.is_symlink():
|
|
1259
|
+
got = Path(os.readlink(str(link)))
|
|
1260
|
+
if not got.is_absolute():
|
|
1261
|
+
got = (link.parent / got)
|
|
1262
|
+
return got.resolve() == target.resolve()
|
|
1263
|
+
if not link.exists():
|
|
1264
|
+
return False
|
|
1265
|
+
ls, ts = link.stat(), target.stat()
|
|
1266
|
+
return ls.st_ino == ts.st_ino and ls.st_dev == ts.st_dev
|
|
1267
|
+
except OSError:
|
|
1268
|
+
return False
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
def bind_to(link: Path, target: Path, force: bool = False) -> Tuple[str, str]:
|
|
1272
|
+
"""Point `link` at `target`: symlink, else hardlink, else copy.
|
|
1273
|
+
|
|
1274
|
+
Returns ``(path, method)`` where method is ``symlink``, ``hardlink``,
|
|
1275
|
+
``copy``, ``skip``, or ``already``.
|
|
1276
|
+
"""
|
|
1277
|
+
target = Path(target)
|
|
1278
|
+
if not target.exists():
|
|
1279
|
+
raise FileNotFoundError(target)
|
|
1280
|
+
link = Path(link)
|
|
1281
|
+
link.parent.mkdir(parents=True, exist_ok=True)
|
|
1282
|
+
if _bound_to(link, target):
|
|
1283
|
+
return str(link), "already"
|
|
1284
|
+
if link.exists() or link.is_symlink():
|
|
1285
|
+
if (
|
|
1286
|
+
not force
|
|
1287
|
+
and _is_foreign_instruction_file(link)
|
|
1288
|
+
and not _is_git_symlink_stub(link)
|
|
1289
|
+
):
|
|
1290
|
+
try:
|
|
1291
|
+
same = target.exists() and _read(link) == _read(target)
|
|
1292
|
+
except OSError:
|
|
1293
|
+
same = False
|
|
1294
|
+
if not same:
|
|
1295
|
+
return "", "skip"
|
|
1296
|
+
link.unlink()
|
|
1297
|
+
rel = os.path.relpath(str(target), start=str(link.parent))
|
|
1298
|
+
try:
|
|
1299
|
+
os.symlink(rel, str(link))
|
|
1300
|
+
return str(link), "symlink"
|
|
1301
|
+
except OSError:
|
|
1302
|
+
pass
|
|
1303
|
+
try:
|
|
1304
|
+
os.link(str(target), str(link))
|
|
1305
|
+
return str(link), "hardlink"
|
|
1306
|
+
except OSError:
|
|
1307
|
+
shutil.copy2(str(target), str(link))
|
|
1308
|
+
return str(link), "copy"
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def write_instruction_pair(directory: Path, body: str) -> List[str]:
|
|
1312
|
+
"""Write AGENTS.md, then bind CLAUDE.md to it.
|
|
1313
|
+
|
|
1314
|
+
If CLAUDE.md is foreign, leave it and append a pointer. If AGENTS.md is
|
|
1315
|
+
foreign, skip the directory.
|
|
1316
|
+
"""
|
|
1317
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
1318
|
+
agents = directory / "AGENTS.md"
|
|
1319
|
+
claude = directory / "CLAUDE.md"
|
|
1320
|
+
if _is_foreign_instruction_file(agents):
|
|
1321
|
+
return []
|
|
1322
|
+
_write(agents, body)
|
|
1323
|
+
written = [str(agents)]
|
|
1324
|
+
if _is_foreign_instruction_file(claude):
|
|
1325
|
+
_ensure_claude_pointer(claude)
|
|
1326
|
+
written.append(str(claude))
|
|
1327
|
+
return written
|
|
1328
|
+
bound, _method = bind_to(claude, agents)
|
|
1329
|
+
if bound:
|
|
1330
|
+
written.append(bound)
|
|
1331
|
+
return written
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
def bind_dir_to_canonical(directory: Path, canonical: Path) -> Tuple[List[str], List[str]]:
|
|
1335
|
+
"""AGENTS.md and CLAUDE.md in `directory` bound to the canonical user file."""
|
|
1336
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
1337
|
+
written: List[str] = []
|
|
1338
|
+
warnings: List[str] = []
|
|
1339
|
+
agents = directory / "AGENTS.md"
|
|
1340
|
+
claude = directory / "CLAUDE.md"
|
|
1341
|
+
if _is_foreign_instruction_file(agents):
|
|
1342
|
+
return written, warnings
|
|
1343
|
+
path, method = bind_to(agents, canonical)
|
|
1344
|
+
_bind_collect(path, method, written, warnings)
|
|
1345
|
+
if _is_foreign_instruction_file(claude):
|
|
1346
|
+
_ensure_claude_pointer(claude)
|
|
1347
|
+
written.append(str(claude))
|
|
1348
|
+
return written, warnings
|
|
1349
|
+
path, method = bind_to(claude, agents if agents.exists() else canonical)
|
|
1350
|
+
_bind_collect(path, method, written, warnings)
|
|
1351
|
+
return written, warnings
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
def bind_claude_home(canonical: Path) -> Tuple[List[str], List[str]]:
|
|
1355
|
+
"""~/.claude/AGENTS.md and CLAUDE.md → canonical (replaces foreign CLAUDE.md)."""
|
|
1356
|
+
CLAUDE_HOME.mkdir(parents=True, exist_ok=True)
|
|
1357
|
+
written: List[str] = []
|
|
1358
|
+
warnings: List[str] = []
|
|
1359
|
+
for name in ("AGENTS.md", "CLAUDE.md"):
|
|
1360
|
+
dest = CLAUDE_HOME / name
|
|
1361
|
+
path, method = bind_to(dest, canonical, force=True)
|
|
1362
|
+
if path:
|
|
1363
|
+
written.append(path)
|
|
1364
|
+
if method == "copy":
|
|
1365
|
+
warnings.append(
|
|
1366
|
+
f"{dest}: bound by copy (symlink/hardlink failed — "
|
|
1367
|
+
"edit AGENTS.md only; re-run python -m agent_memory sync after changes or use Developer Mode / native FS)"
|
|
1368
|
+
)
|
|
1369
|
+
return written, warnings
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
def _bind_collect(path: str, method: str, written: List[str], warnings: List[str]) -> None:
|
|
1373
|
+
if path:
|
|
1374
|
+
written.append(path)
|
|
1375
|
+
if method == "copy":
|
|
1376
|
+
warnings.append(
|
|
1377
|
+
f"{path}: bound by copy (symlink/hardlink failed — may drift; see LAYOUT.md / README)"
|
|
1378
|
+
)
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
def repair_instruction_stub(directory: Path) -> List[str]:
|
|
1382
|
+
"""Replace a 9-byte git-symlink checkout (or an identical copy) with a bind to AGENTS.md.
|
|
1383
|
+
|
|
1384
|
+
The engine repo root always binds: CLAUDE.md and AGENTS.md are the same content.
|
|
1385
|
+
"""
|
|
1386
|
+
agents = directory / "AGENTS.md"
|
|
1387
|
+
claude = directory / "CLAUDE.md"
|
|
1388
|
+
if not agents.exists():
|
|
1389
|
+
return []
|
|
1390
|
+
force = directory.resolve() == ROOT.resolve()
|
|
1391
|
+
same = False
|
|
1392
|
+
try:
|
|
1393
|
+
same = claude.exists() and not claude.is_symlink() and _read(claude) == _read(agents)
|
|
1394
|
+
except OSError:
|
|
1395
|
+
same = False
|
|
1396
|
+
stub = _is_git_symlink_stub(claude)
|
|
1397
|
+
if _is_foreign_instruction_file(claude) and not stub and not same and not force:
|
|
1398
|
+
return []
|
|
1399
|
+
if force and (claude.exists() or claude.is_symlink()) and not _bound_to(claude, agents):
|
|
1400
|
+
claude.unlink()
|
|
1401
|
+
bound, method = bind_to(claude, agents)
|
|
1402
|
+
return [bound] if bound else []
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
def inject_into_repo(p: Project) -> List[str]:
|
|
1406
|
+
written: List[str] = []
|
|
1407
|
+
repo = p.path_obj
|
|
1408
|
+
if not repo.is_dir() or is_engine_repo(repo):
|
|
1409
|
+
return written
|
|
1410
|
+
ensure_project_file(p)
|
|
1411
|
+
written.append(str(p.detail_path))
|
|
1412
|
+
written.append(str(p.user_link_path))
|
|
1413
|
+
written.extend(write_instruction_pair(repo / ".agents", project_agents_text(p)))
|
|
1414
|
+
return written
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
def _machine_paths_block() -> str:
|
|
1418
|
+
py = sys.executable
|
|
1419
|
+
roots = ", ".join(f"`{r}`" for r in scan_roots()) or "`scan.json` roots"
|
|
1420
|
+
return (
|
|
1421
|
+
f"Install (engine): `{ROOT}` \n"
|
|
1422
|
+
f"User memory: `{USER_MEMORY}` \n"
|
|
1423
|
+
f"Project memory: `<repo>/.agents/memory` \n"
|
|
1424
|
+
f"Agent rule: `{AGENTS_RULES / agent_rule_name()}` \n"
|
|
1425
|
+
f"Scan roots: {roots}\n\n"
|
|
1426
|
+
"Scripts (any workspace, after `pip install -e` this clone):\n\n"
|
|
1427
|
+
"```powershell\n"
|
|
1428
|
+
f"{py} -m agent_memory inventory\n"
|
|
1429
|
+
f"{py} -m agent_memory inventory --json\n"
|
|
1430
|
+
f"{py} -m agent_memory sync\n"
|
|
1431
|
+
f"{py} -m agent_memory ingest catalog\n"
|
|
1432
|
+
f"{py} -m agent_memory ingest run\n"
|
|
1433
|
+
"```\n\n"
|
|
1434
|
+
"Register:\n\n"
|
|
1435
|
+
"```powershell\n"
|
|
1436
|
+
f'{py} -m agent_memory inventory --register SLUG "C:\\path\\to\\repo" "role here" "stack here"\n'
|
|
1437
|
+
"```\n"
|
|
1438
|
+
)
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
def machine_skill_text() -> str:
|
|
1442
|
+
template = _read(SKILL_TEMPLATE)
|
|
1443
|
+
if not template:
|
|
1444
|
+
return ""
|
|
1445
|
+
if PATHS_BEGIN in template and PATHS_END in template:
|
|
1446
|
+
pre, rest = template.split(PATHS_BEGIN, 1)
|
|
1447
|
+
_, post = rest.split(PATHS_END, 1)
|
|
1448
|
+
return (
|
|
1449
|
+
pre
|
|
1450
|
+
+ PATHS_BEGIN
|
|
1451
|
+
+ "\n"
|
|
1452
|
+
+ _machine_paths_block().rstrip()
|
|
1453
|
+
+ "\n"
|
|
1454
|
+
+ PATHS_END
|
|
1455
|
+
+ post
|
|
1456
|
+
)
|
|
1457
|
+
return template
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
def install_skills() -> List[str]:
|
|
1461
|
+
text = machine_skill_text()
|
|
1462
|
+
written: List[str] = []
|
|
1463
|
+
if text:
|
|
1464
|
+
targets = [
|
|
1465
|
+
Path.home() / ".cursor" / "skills" / "memory-sync" / "SKILL.md",
|
|
1466
|
+
Path.home() / ".agents" / "skills" / "memory-sync" / "SKILL.md",
|
|
1467
|
+
Path.home() / ".gemini" / "config" / "skills" / "memory-sync" / "SKILL.md",
|
|
1468
|
+
]
|
|
1469
|
+
for path in targets:
|
|
1470
|
+
_write(path, text)
|
|
1471
|
+
written.append(str(path))
|
|
1472
|
+
|
|
1473
|
+
distill_src = ROOT / "skills" / "memory-distill" / "SKILL.md"
|
|
1474
|
+
if distill_src.is_file():
|
|
1475
|
+
d_text = _read(distill_src)
|
|
1476
|
+
d_targets = [
|
|
1477
|
+
Path.home() / ".cursor" / "skills" / "memory-distill" / "SKILL.md",
|
|
1478
|
+
Path.home() / ".agents" / "skills" / "memory-distill" / "SKILL.md",
|
|
1479
|
+
Path.home() / ".gemini" / "config" / "skills" / "memory-distill" / "SKILL.md",
|
|
1480
|
+
]
|
|
1481
|
+
for path in d_targets:
|
|
1482
|
+
_write(path, d_text)
|
|
1483
|
+
written.append(str(path))
|
|
1484
|
+
return written
|
|
1485
|
+
|
|
1486
|
+
|
|
1487
|
+
def sync_injection(include_repos: bool = True) -> Tuple[List[str], List[str]]:
|
|
1488
|
+
ensure_memory_layout()
|
|
1489
|
+
written: List[str] = []
|
|
1490
|
+
warnings: List[str] = []
|
|
1491
|
+
body = gemini_agents_text()
|
|
1492
|
+
AGENTS_HOME.mkdir(parents=True, exist_ok=True)
|
|
1493
|
+
_write(HOME_AGENTS, body)
|
|
1494
|
+
written.append(str(HOME_AGENTS))
|
|
1495
|
+
written.extend(repair_instruction_stub(AGENTS_HOME))
|
|
1496
|
+
for fn in (
|
|
1497
|
+
lambda: bind_dir_to_canonical(INJECTION_GEMINI.parent, HOME_AGENTS),
|
|
1498
|
+
lambda: bind_dir_to_canonical(zed_config_dir(), HOME_AGENTS),
|
|
1499
|
+
):
|
|
1500
|
+
w, warn = fn()
|
|
1501
|
+
written.extend(w)
|
|
1502
|
+
warnings.extend(warn)
|
|
1503
|
+
claude_written, claude_warn = bind_claude_home(HOME_AGENTS)
|
|
1504
|
+
written.extend(claude_written)
|
|
1505
|
+
warnings.extend(claude_warn)
|
|
1506
|
+
AGENTS_RULES.mkdir(parents=True, exist_ok=True)
|
|
1507
|
+
rule = canonical_agent_rule()
|
|
1508
|
+
_write(rule, agent_rule_text())
|
|
1509
|
+
written.append(str(rule))
|
|
1510
|
+
host_rules, host_warn = bind_host_rules(rule)
|
|
1511
|
+
written.extend(host_rules)
|
|
1512
|
+
warnings.extend(host_warn)
|
|
1513
|
+
written.extend(purge_legacy_rules_everywhere())
|
|
1514
|
+
written.extend(install_skills())
|
|
1515
|
+
written.extend(mirror_skills_to_zed())
|
|
1516
|
+
written.append(merge_agent_mcp())
|
|
1517
|
+
written.append(merge_zed_mcp())
|
|
1518
|
+
if include_repos:
|
|
1519
|
+
for p in parse_projects():
|
|
1520
|
+
written.extend(inject_into_repo(p))
|
|
1521
|
+
written.extend(purge_legacy_rules_everywhere())
|
|
1522
|
+
return written, warnings
|
|
1523
|
+
|
|
1524
|
+
|
|
1525
|
+
def file_id(path: Path) -> str:
|
|
1526
|
+
path = path.resolve()
|
|
1527
|
+
try:
|
|
1528
|
+
rel = path.relative_to(USER_MEMORY.resolve())
|
|
1529
|
+
return f"user/{rel.as_posix()}"
|
|
1530
|
+
except ValueError:
|
|
1531
|
+
pass
|
|
1532
|
+
for p in parse_projects():
|
|
1533
|
+
if not p.path_obj.is_dir():
|
|
1534
|
+
continue
|
|
1535
|
+
root = (p.path_obj / ".agents" / "memory").resolve()
|
|
1536
|
+
try:
|
|
1537
|
+
rel = path.relative_to(root)
|
|
1538
|
+
return f"project/{p.slug}/{rel.as_posix()}"
|
|
1539
|
+
except ValueError:
|
|
1540
|
+
continue
|
|
1541
|
+
return path.as_posix()
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
def resolve_memory_path(rel: str) -> Path:
|
|
1545
|
+
rel = rel.replace("\\", "/").lstrip("/")
|
|
1546
|
+
if rel.startswith("user/"):
|
|
1547
|
+
return (USER_MEMORY / rel[len("user/") :]).resolve()
|
|
1548
|
+
if rel.startswith("project/"):
|
|
1549
|
+
rest = rel[len("project/") :]
|
|
1550
|
+
slug, _, inner = rest.partition("/")
|
|
1551
|
+
p = projects_by_slug().get(slug)
|
|
1552
|
+
if not p:
|
|
1553
|
+
raise FileNotFoundError(rel)
|
|
1554
|
+
if not inner:
|
|
1555
|
+
return p.detail_path
|
|
1556
|
+
return (p.memory_dir / inner).resolve()
|
|
1557
|
+
legacy = USER_MEMORY / rel
|
|
1558
|
+
if legacy.exists():
|
|
1559
|
+
return legacy
|
|
1560
|
+
if rel.startswith("projects/"):
|
|
1561
|
+
slug = Path(rel).stem
|
|
1562
|
+
p = projects_by_slug().get(slug)
|
|
1563
|
+
if p:
|
|
1564
|
+
return p.detail_path
|
|
1565
|
+
raise FileNotFoundError(rel)
|
|
1566
|
+
|
|
1567
|
+
|
|
1568
|
+
def _markdown_under(root: Path) -> List[Path]:
|
|
1569
|
+
if not root.is_dir():
|
|
1570
|
+
return []
|
|
1571
|
+
return sorted(p for p in root.rglob("*.md") if p.is_file())
|
|
1572
|
+
|
|
1573
|
+
|
|
1574
|
+
def iter_user_memory_files() -> List[Path]:
|
|
1575
|
+
return _markdown_under(USER_MEMORY)
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
def iter_project_memory_files(slug: str = "") -> List[Path]:
|
|
1579
|
+
files: List[Path] = []
|
|
1580
|
+
for p in parse_projects():
|
|
1581
|
+
if slug and p.slug != slug:
|
|
1582
|
+
continue
|
|
1583
|
+
if p.path_obj.is_dir():
|
|
1584
|
+
folder = p.path_obj / ".agents" / "memory"
|
|
1585
|
+
files.extend(_markdown_under(folder))
|
|
1586
|
+
continue
|
|
1587
|
+
if p.detail_path.exists():
|
|
1588
|
+
files.append(p.detail_path)
|
|
1589
|
+
return files
|
|
1590
|
+
|
|
1591
|
+
|
|
1592
|
+
def iter_memory_files(project: str = "") -> List[Path]:
|
|
1593
|
+
"""Overarching retrieval: user store plus every (or one) project store."""
|
|
1594
|
+
seen: set[str] = set()
|
|
1595
|
+
out: List[Path] = []
|
|
1596
|
+
chunks = iter_user_memory_files()
|
|
1597
|
+
chunks.extend(iter_project_memory_files(project.strip() if project else ""))
|
|
1598
|
+
if project:
|
|
1599
|
+
# still include user layer so cross-cutting facts remain findable
|
|
1600
|
+
pass
|
|
1601
|
+
for path in chunks:
|
|
1602
|
+
key = str(path.resolve()).lower()
|
|
1603
|
+
if key in seen:
|
|
1604
|
+
continue
|
|
1605
|
+
seen.add(key)
|
|
1606
|
+
out.append(path)
|
|
1607
|
+
return out
|
|
1608
|
+
|
|
1609
|
+
|
|
1610
|
+
_MEMORY_FILE_CACHE: dict[str, Tuple[float, List[str]]] = {}
|
|
1611
|
+
|
|
1612
|
+
|
|
1613
|
+
def clear_memory_cache() -> None:
|
|
1614
|
+
_MEMORY_FILE_CACHE.clear()
|
|
1615
|
+
|
|
1616
|
+
|
|
1617
|
+
def _read_cached_lines(path: Path) -> List[str]:
|
|
1618
|
+
key = str(path.resolve())
|
|
1619
|
+
try:
|
|
1620
|
+
mtime = path.stat().st_mtime
|
|
1621
|
+
except OSError:
|
|
1622
|
+
return []
|
|
1623
|
+
cached = _MEMORY_FILE_CACHE.get(key)
|
|
1624
|
+
if cached is not None and cached[0] == mtime:
|
|
1625
|
+
return cached[1]
|
|
1626
|
+
text = _read(path)
|
|
1627
|
+
lines = text.splitlines()
|
|
1628
|
+
_MEMORY_FILE_CACHE[key] = (mtime, lines)
|
|
1629
|
+
return lines
|
|
1630
|
+
|
|
1631
|
+
|
|
1632
|
+
def search_memory(query: str, project: str = "", limit: int = 20) -> List[dict]:
|
|
1633
|
+
q = query.lower().strip()
|
|
1634
|
+
files = iter_memory_files(project=project)
|
|
1635
|
+
hits: List[dict] = []
|
|
1636
|
+
for path in files:
|
|
1637
|
+
lines = _read_cached_lines(path)
|
|
1638
|
+
for i, line in enumerate(lines, 1):
|
|
1639
|
+
if not q or q not in line.lower():
|
|
1640
|
+
continue
|
|
1641
|
+
ident = file_id(path)
|
|
1642
|
+
hits.append(
|
|
1643
|
+
{
|
|
1644
|
+
"id": f"{ident}:{i}",
|
|
1645
|
+
"file": ident,
|
|
1646
|
+
"line": i,
|
|
1647
|
+
"text": line.strip(),
|
|
1648
|
+
}
|
|
1649
|
+
)
|
|
1650
|
+
if len(hits) >= limit:
|
|
1651
|
+
return hits
|
|
1652
|
+
return hits
|
|
1653
|
+
|
|
1654
|
+
|
|
1655
|
+
KIND_FOLDERS = {
|
|
1656
|
+
"concept": "concepts",
|
|
1657
|
+
"concepts": "concepts",
|
|
1658
|
+
"entity": "entities",
|
|
1659
|
+
"entities": "entities",
|
|
1660
|
+
"workflow": "workflows",
|
|
1661
|
+
"workflows": "workflows",
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
KIND_HELP = (
|
|
1665
|
+
"add_memory needs kind=concept|entity|workflow|project|note|scratch|"
|
|
1666
|
+
"research|plans|tasks|roadmap|waves|decision|proposed|implemented|rejected|"
|
|
1667
|
+
"staging plus name=. collection= is a notes/ folder or a note class "
|
|
1668
|
+
"(feature, bug-fix, simplification, architecture, process, testing). "
|
|
1669
|
+
"plans/tasks/waves/roadmap/decisions are 001-topic.md. "
|
|
1670
|
+
"project= alone writes <repo>/.agents/memory/staging/captured.md (inbox, distill it)"
|
|
1671
|
+
)
|
|
1672
|
+
|
|
1673
|
+
|
|
1674
|
+
def slugify_name(name: str) -> str:
|
|
1675
|
+
name = (name or "").strip().replace("\\", "/").split("/")[-1]
|
|
1676
|
+
if name.lower().endswith(".md"):
|
|
1677
|
+
name = name[:-3]
|
|
1678
|
+
name = re.sub(r"[^a-zA-Z0-9._-]+", "-", name).strip("-._").lower()
|
|
1679
|
+
if not name:
|
|
1680
|
+
raise ValueError("empty name")
|
|
1681
|
+
return name
|
|
1682
|
+
|
|
1683
|
+
|
|
1684
|
+
def next_seq(folder: Path) -> int:
|
|
1685
|
+
n = 0
|
|
1686
|
+
if folder.is_dir():
|
|
1687
|
+
for path in folder.glob("*.md"):
|
|
1688
|
+
match = SEQ_RE.match(path.stem)
|
|
1689
|
+
if match:
|
|
1690
|
+
n = max(n, int(match.group(1)))
|
|
1691
|
+
return n + 1
|
|
1692
|
+
|
|
1693
|
+
|
|
1694
|
+
def sequential_path(folder: Path, name: str) -> Path:
|
|
1695
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
1696
|
+
slug = slugify_name(name)
|
|
1697
|
+
numbered = SEQ_RE.match(slug)
|
|
1698
|
+
if numbered and "-" in slug:
|
|
1699
|
+
return folder / f"{slug}.md"
|
|
1700
|
+
existing = sorted(folder.glob(f"*-{slug}.md")) if folder.is_dir() else []
|
|
1701
|
+
plain = folder / f"{slug}.md"
|
|
1702
|
+
if plain.exists():
|
|
1703
|
+
return plain
|
|
1704
|
+
if existing:
|
|
1705
|
+
return existing[0]
|
|
1706
|
+
return folder / f"{next_seq(folder):03d}-{slug}.md"
|
|
1707
|
+
name = (name or "").strip().replace("\\", "/").split("/")[-1]
|
|
1708
|
+
if name.lower().endswith(".md"):
|
|
1709
|
+
name = name[:-3]
|
|
1710
|
+
name = re.sub(r"[^a-zA-Z0-9._-]+", "-", name).strip("-._").lower()
|
|
1711
|
+
if not name:
|
|
1712
|
+
raise ValueError("empty name")
|
|
1713
|
+
return name
|
|
1714
|
+
|
|
1715
|
+
|
|
1716
|
+
def _heading_from_stem(stem: str) -> str:
|
|
1717
|
+
return stem.replace("-", " ").replace("_", " ").strip().title()
|
|
1718
|
+
|
|
1719
|
+
|
|
1720
|
+
def memory_file_for(
|
|
1721
|
+
kind: str = "",
|
|
1722
|
+
name: str = "",
|
|
1723
|
+
project: str = "",
|
|
1724
|
+
collection: str = "",
|
|
1725
|
+
) -> Path:
|
|
1726
|
+
"""Resolve where a fact belongs."""
|
|
1727
|
+
kind = (kind or "").strip().lower()
|
|
1728
|
+
name = (name or "").strip()
|
|
1729
|
+
project = (project or "").strip()
|
|
1730
|
+
collection = (collection or "").strip().lower()
|
|
1731
|
+
if kind in {"notes"}:
|
|
1732
|
+
kind = "note"
|
|
1733
|
+
|
|
1734
|
+
if kind == "scratch" or collection == "scratch":
|
|
1735
|
+
return USER_MEMORY / "notes" / "scratch" / f"{slugify_name(name or 'captured')}.md"
|
|
1736
|
+
|
|
1737
|
+
if kind in NOTE_LIFECYCLES:
|
|
1738
|
+
if not project:
|
|
1739
|
+
raise ValueError("proposed/implemented/rejected need project=")
|
|
1740
|
+
p = projects_by_slug().get(project)
|
|
1741
|
+
if not p:
|
|
1742
|
+
raise ValueError(f"unknown project '{project}' — register it first")
|
|
1743
|
+
ensure_project_file(p)
|
|
1744
|
+
cls = collection or "architecture"
|
|
1745
|
+
if cls not in NOTE_CLASSES:
|
|
1746
|
+
cls = slugify_name(cls)
|
|
1747
|
+
folder = p.memory_dir / "notes" / kind / cls
|
|
1748
|
+
return sequential_path(folder, name or "note")
|
|
1749
|
+
|
|
1750
|
+
if kind in PROJECT_ARTIFACTS:
|
|
1751
|
+
slug = project or name
|
|
1752
|
+
p = projects_by_slug().get(slug)
|
|
1753
|
+
if not p:
|
|
1754
|
+
raise ValueError(f"unknown project '{slug}' — register it first")
|
|
1755
|
+
ensure_project_file(p)
|
|
1756
|
+
folder_name = PROJECT_ARTIFACTS[kind]
|
|
1757
|
+
dest_dir = p.memory_dir / folder_name
|
|
1758
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
1759
|
+
if folder_name == "staging":
|
|
1760
|
+
stem = slugify_name(name) if name and name != slug else "captured"
|
|
1761
|
+
if stem in {"from-chats", "chats"}:
|
|
1762
|
+
stem = "from-chats"
|
|
1763
|
+
return dest_dir / f"{stem}.md"
|
|
1764
|
+
stem = slugify_name(name) if name and name != slug else folder_name.rstrip("s")
|
|
1765
|
+
if folder_name in SEQUENTIAL_FOLDERS:
|
|
1766
|
+
return sequential_path(dest_dir, stem)
|
|
1767
|
+
return dest_dir / f"{stem}.md"
|
|
1768
|
+
|
|
1769
|
+
if kind == "note":
|
|
1770
|
+
registered = projects_by_slug()
|
|
1771
|
+
stem = slugify_name(name) if name else "captured"
|
|
1772
|
+
if collection:
|
|
1773
|
+
coll = collection.strip()
|
|
1774
|
+
if coll in registered:
|
|
1775
|
+
return (
|
|
1776
|
+
USER_MEMORY
|
|
1777
|
+
/ "notes"
|
|
1778
|
+
/ "projects"
|
|
1779
|
+
/ slugify_name(coll)
|
|
1780
|
+
/ f"{stem}.md"
|
|
1781
|
+
)
|
|
1782
|
+
return USER_MEMORY / "notes" / slugify_name(coll) / f"{stem}.md"
|
|
1783
|
+
if project:
|
|
1784
|
+
return (
|
|
1785
|
+
USER_MEMORY
|
|
1786
|
+
/ "notes"
|
|
1787
|
+
/ "projects"
|
|
1788
|
+
/ slugify_name(project)
|
|
1789
|
+
/ f"{stem}.md"
|
|
1790
|
+
)
|
|
1791
|
+
return USER_MEMORY / "notes" / "scratch" / f"{stem}.md"
|
|
1792
|
+
|
|
1793
|
+
if kind in {"project", "projects"}:
|
|
1794
|
+
slug = slugify_name(name or project)
|
|
1795
|
+
p = projects_by_slug().get(slug)
|
|
1796
|
+
if p:
|
|
1797
|
+
ensure_project_file(p)
|
|
1798
|
+
return p.user_link_path
|
|
1799
|
+
return USER_MEMORY / "projects" / slug / "README.md"
|
|
1800
|
+
|
|
1801
|
+
if kind in KIND_FOLDERS:
|
|
1802
|
+
stem = slugify_name(name or project)
|
|
1803
|
+
return USER_MEMORY / KIND_FOLDERS[kind] / f"{stem}.md"
|
|
1804
|
+
|
|
1805
|
+
if project:
|
|
1806
|
+
p = projects_by_slug().get(project)
|
|
1807
|
+
if not p:
|
|
1808
|
+
raise ValueError(f"unknown project '{project}' — register it first")
|
|
1809
|
+
ensure_project_file(p)
|
|
1810
|
+
return p.memory_dir / "staging" / "captured.md"
|
|
1811
|
+
|
|
1812
|
+
raise ValueError(KIND_HELP)
|
|
1813
|
+
|
|
1814
|
+
|
|
1815
|
+
def _already_has_fact(text: str, fact: str) -> bool:
|
|
1816
|
+
needle = fact.strip().lstrip("-").strip().lower()
|
|
1817
|
+
if not needle:
|
|
1818
|
+
return True
|
|
1819
|
+
for line in text.splitlines():
|
|
1820
|
+
if line.strip().lstrip("-").strip().lower() == needle:
|
|
1821
|
+
return True
|
|
1822
|
+
return False
|
|
1823
|
+
|
|
1824
|
+
|
|
1825
|
+
def _append_bullet(path: Path, fact: str) -> str:
|
|
1826
|
+
bullet = fact if fact.lstrip().startswith("- ") else f"- {fact}"
|
|
1827
|
+
if path.exists():
|
|
1828
|
+
text = _read(path)
|
|
1829
|
+
if _already_has_fact(text, fact):
|
|
1830
|
+
return file_id(path)
|
|
1831
|
+
body = text.rstrip()
|
|
1832
|
+
if body and not body.splitlines()[-1].lstrip().startswith("- "):
|
|
1833
|
+
body += "\n"
|
|
1834
|
+
_write(path, body + f"\n{bullet}\n")
|
|
1835
|
+
return file_id(path)
|
|
1836
|
+
if not path.exists():
|
|
1837
|
+
header = STAGING_HEADER if path.parent.name == "staging" else f"# {_heading_from_stem(path.stem)}\n"
|
|
1838
|
+
_write(path, f"{header}\n{bullet}\n")
|
|
1839
|
+
return file_id(path)
|
|
1840
|
+
|
|
1841
|
+
|
|
1842
|
+
def _append_repo_captured(path: Path, fact: str) -> str:
|
|
1843
|
+
text = _read(path)
|
|
1844
|
+
if _already_has_fact(text, fact):
|
|
1845
|
+
return file_id(path)
|
|
1846
|
+
if "## Captured" not in text:
|
|
1847
|
+
text = text.rstrip() + "\n\n## Captured\n\n"
|
|
1848
|
+
lines = [ln for ln in text.splitlines() if ln.strip() != "- (none yet)"]
|
|
1849
|
+
if not any(ln.strip() == "## Captured" for ln in lines):
|
|
1850
|
+
lines.extend(["", "## Captured", ""])
|
|
1851
|
+
out: List[str] = []
|
|
1852
|
+
inserted = False
|
|
1853
|
+
for ln in lines:
|
|
1854
|
+
out.append(ln)
|
|
1855
|
+
if not inserted and ln.strip() == "## Captured":
|
|
1856
|
+
out.append(f"- {fact}")
|
|
1857
|
+
inserted = True
|
|
1858
|
+
if not inserted:
|
|
1859
|
+
out.append(f"- {fact}")
|
|
1860
|
+
_write(path, "\n".join(out))
|
|
1861
|
+
return file_id(path)
|
|
1862
|
+
|
|
1863
|
+
|
|
1864
|
+
def add_memory(
|
|
1865
|
+
fact: str,
|
|
1866
|
+
kind: str = "",
|
|
1867
|
+
name: str = "",
|
|
1868
|
+
project: str = "",
|
|
1869
|
+
collection: str = "",
|
|
1870
|
+
) -> str:
|
|
1871
|
+
"""File a durable fact. kind+name → taxonomy; project= alone → staging/captured.md (inbox)."""
|
|
1872
|
+
fact = fact.strip()
|
|
1873
|
+
if not fact:
|
|
1874
|
+
raise ValueError("empty fact")
|
|
1875
|
+
path = memory_file_for(
|
|
1876
|
+
kind=kind, name=name, project=project, collection=collection
|
|
1877
|
+
)
|
|
1878
|
+
existed = path.exists()
|
|
1879
|
+
loc = _append_bullet(path, fact)
|
|
1880
|
+
k = (kind or "").strip().lower()
|
|
1881
|
+
if k in REVISE_IN_PLACE_KINDS and existed:
|
|
1882
|
+
return (
|
|
1883
|
+
f"{loc} — revise this file in place when facts change; "
|
|
1884
|
+
"do not only append bullets"
|
|
1885
|
+
)
|
|
1886
|
+
return loc
|
|
1887
|
+
|
|
1888
|
+
|
|
1889
|
+
def get_project_memories(project: str) -> str:
|
|
1890
|
+
p = projects_by_slug().get(project)
|
|
1891
|
+
if not p:
|
|
1892
|
+
return f"Unknown project '{project}'. See PROJECTS.md."
|
|
1893
|
+
header = (
|
|
1894
|
+
f"slug: {p.slug}\npath: {p.path}\nrole: {p.role}\n"
|
|
1895
|
+
f"stack: {p.stack}\nstatus: {p.status}\n"
|
|
1896
|
+
f"file: {p.detail_path}\n\n"
|
|
1897
|
+
)
|
|
1898
|
+
files = _markdown_under(p.memory_dir) if p.path_obj.is_dir() else []
|
|
1899
|
+
if not files and p.detail_path.exists():
|
|
1900
|
+
files = [p.detail_path]
|
|
1901
|
+
if not files:
|
|
1902
|
+
return header + stub_project_md(p)
|
|
1903
|
+
parts = [header]
|
|
1904
|
+
for path in files:
|
|
1905
|
+
parts.append(f"## {file_id(path)}\n\n{_read(path).rstrip()}\n")
|
|
1906
|
+
return "\n".join(parts)
|
|
1907
|
+
|
|
1908
|
+
|
|
1909
|
+
def delete_memory(memory_id: str) -> str:
|
|
1910
|
+
if ":" not in memory_id:
|
|
1911
|
+
raise ValueError(
|
|
1912
|
+
"id must look like 'user/notes/programming/chat-stores.md:12' "
|
|
1913
|
+
"or 'project/slug/staging/captured.md:8'"
|
|
1914
|
+
)
|
|
1915
|
+
rel, _, line_s = memory_id.rpartition(":")
|
|
1916
|
+
line_no = int(line_s)
|
|
1917
|
+
path = resolve_memory_path(rel)
|
|
1918
|
+
if not path.exists():
|
|
1919
|
+
raise FileNotFoundError(rel)
|
|
1920
|
+
lines = _read(path).splitlines()
|
|
1921
|
+
if line_no < 1 or line_no > len(lines):
|
|
1922
|
+
raise IndexError(memory_id)
|
|
1923
|
+
removed = lines.pop(line_no - 1)
|
|
1924
|
+
_write(path, "\n".join(lines))
|
|
1925
|
+
return removed
|
|
1926
|
+
|
|
1927
|
+
|
|
1928
|
+
def remove_staging_bullet(
|
|
1929
|
+
bullet: str,
|
|
1930
|
+
project: str = "",
|
|
1931
|
+
source_path: str = "",
|
|
1932
|
+
) -> bool:
|
|
1933
|
+
clean_bullet = bullet.strip().lstrip("-").strip().lower()
|
|
1934
|
+
if not clean_bullet:
|
|
1935
|
+
return False
|
|
1936
|
+
candidate_paths: List[Path] = []
|
|
1937
|
+
if source_path:
|
|
1938
|
+
candidate_paths.append(resolve_memory_path(source_path))
|
|
1939
|
+
if project:
|
|
1940
|
+
p = projects_by_slug().get(project)
|
|
1941
|
+
if p:
|
|
1942
|
+
candidate_paths.append(p.memory_dir / "staging" / "captured.md")
|
|
1943
|
+
candidate_paths.append(p.memory_dir / "staging" / "from-chats.md")
|
|
1944
|
+
|
|
1945
|
+
candidate_paths.extend(
|
|
1946
|
+
[
|
|
1947
|
+
USER_MEMORY / "staging" / "captured.md",
|
|
1948
|
+
USER_MEMORY / "staging" / "from-chats.md",
|
|
1949
|
+
]
|
|
1950
|
+
)
|
|
1951
|
+
if (USER_MEMORY / "staging").is_dir():
|
|
1952
|
+
for f in sorted((USER_MEMORY / "staging").glob("*.md")):
|
|
1953
|
+
if f not in candidate_paths:
|
|
1954
|
+
candidate_paths.append(f)
|
|
1955
|
+
for f in sorted((USER_MEMORY / "staging").rglob("*.md")):
|
|
1956
|
+
if f not in candidate_paths:
|
|
1957
|
+
candidate_paths.append(f)
|
|
1958
|
+
|
|
1959
|
+
for path in candidate_paths:
|
|
1960
|
+
if not path.is_file():
|
|
1961
|
+
continue
|
|
1962
|
+
lines = _read(path).splitlines()
|
|
1963
|
+
new_lines: List[str] = []
|
|
1964
|
+
file_modified = False
|
|
1965
|
+
for line in lines:
|
|
1966
|
+
normalized_line = line.strip().lstrip("-").strip().lower()
|
|
1967
|
+
if not file_modified and normalized_line == clean_bullet:
|
|
1968
|
+
file_modified = True
|
|
1969
|
+
continue
|
|
1970
|
+
new_lines.append(line)
|
|
1971
|
+
if file_modified:
|
|
1972
|
+
_write(path, "\n".join(new_lines))
|
|
1973
|
+
_MEMORY_FILE_CACHE.pop(str(path.resolve()), None)
|
|
1974
|
+
return True
|
|
1975
|
+
return False
|
|
1976
|
+
|
|
1977
|
+
|
|
1978
|
+
def promote_bullet(
|
|
1979
|
+
bullet: str,
|
|
1980
|
+
kind: str,
|
|
1981
|
+
name: str,
|
|
1982
|
+
project: str = "",
|
|
1983
|
+
collection: str = "",
|
|
1984
|
+
source_path: str = "",
|
|
1985
|
+
) -> Tuple[str, bool]:
|
|
1986
|
+
"""Promote a staging bullet into a typed memory file and remove it from staging.
|
|
1987
|
+
|
|
1988
|
+
Returns (saved_location, removed_from_staging).
|
|
1989
|
+
"""
|
|
1990
|
+
clean_bullet = bullet.strip().lstrip("-").strip()
|
|
1991
|
+
if not clean_bullet:
|
|
1992
|
+
raise ValueError("empty bullet")
|
|
1993
|
+
|
|
1994
|
+
loc = add_memory(
|
|
1995
|
+
clean_bullet,
|
|
1996
|
+
kind=kind,
|
|
1997
|
+
name=name,
|
|
1998
|
+
project=project,
|
|
1999
|
+
collection=collection,
|
|
2000
|
+
)
|
|
2001
|
+
removed = remove_staging_bullet(clean_bullet, project=project, source_path=source_path)
|
|
2002
|
+
return loc, removed
|
|
2003
|
+
|
|
2004
|
+
|
|
2005
|
+
_STAGING_BULLET_RE = re.compile(
|
|
2006
|
+
r"^\[(?P<title>[^\]]+?)(?:\s@\s(?P<origin>[^\]]+))?\]\s*(?P<body>.+)$"
|
|
2007
|
+
)
|
|
2008
|
+
|
|
2009
|
+
|
|
2010
|
+
def parse_staging_bullet(text: str) -> dict:
|
|
2011
|
+
text = text.strip()
|
|
2012
|
+
match = _STAGING_BULLET_RE.match(text)
|
|
2013
|
+
if match:
|
|
2014
|
+
return {
|
|
2015
|
+
"bullet": text,
|
|
2016
|
+
"title": match.group("title").strip(),
|
|
2017
|
+
"origin": (match.group("origin") or "").strip(),
|
|
2018
|
+
"text": match.group("body").strip(),
|
|
2019
|
+
}
|
|
2020
|
+
return {"bullet": text, "title": "", "origin": "", "text": text}
|
|
2021
|
+
|
|
2022
|
+
|
|
2023
|
+
def _collect_staging_paths(project: str = "") -> List[Path]:
|
|
2024
|
+
candidate_paths: List[Path] = []
|
|
2025
|
+
if project:
|
|
2026
|
+
p = projects_by_slug().get(project)
|
|
2027
|
+
if p:
|
|
2028
|
+
candidate_paths.append(p.memory_dir / "staging" / "captured.md")
|
|
2029
|
+
candidate_paths.append(p.memory_dir / "staging" / "from-chats.md")
|
|
2030
|
+
else:
|
|
2031
|
+
candidate_paths.extend(
|
|
2032
|
+
[
|
|
2033
|
+
USER_MEMORY / "staging" / "captured.md",
|
|
2034
|
+
USER_MEMORY / "staging" / "from-chats.md",
|
|
2035
|
+
]
|
|
2036
|
+
)
|
|
2037
|
+
if (USER_MEMORY / "staging").is_dir():
|
|
2038
|
+
for f in sorted((USER_MEMORY / "staging").rglob("*.md")):
|
|
2039
|
+
if f not in candidate_paths:
|
|
2040
|
+
candidate_paths.append(f)
|
|
2041
|
+
return candidate_paths
|
|
2042
|
+
|
|
2043
|
+
|
|
2044
|
+
def _staging_file_meta(path: Path) -> dict:
|
|
2045
|
+
fid = file_id(path)
|
|
2046
|
+
meta = {"file": fid, "ingest_id": "", "source": fid, "project": ""}
|
|
2047
|
+
try:
|
|
2048
|
+
rel_u = path.resolve().relative_to(USER_MEMORY.resolve()).as_posix()
|
|
2049
|
+
except ValueError:
|
|
2050
|
+
rel_u = ""
|
|
2051
|
+
if rel_u.startswith("staging/ingest/"):
|
|
2052
|
+
parts = rel_u.split("/")
|
|
2053
|
+
if len(parts) >= 3:
|
|
2054
|
+
meta["ingest_id"] = parts[2]
|
|
2055
|
+
meta["source"] = parts[2]
|
|
2056
|
+
for slug, proj in projects_by_slug().items():
|
|
2057
|
+
try:
|
|
2058
|
+
path.resolve().relative_to(proj.memory_dir.resolve())
|
|
2059
|
+
meta["project"] = slug
|
|
2060
|
+
break
|
|
2061
|
+
except ValueError:
|
|
2062
|
+
continue
|
|
2063
|
+
if meta["ingest_id"]:
|
|
2064
|
+
from .ingest_config import get_source, load_ingest
|
|
2065
|
+
|
|
2066
|
+
src = get_source(meta["ingest_id"], load_ingest())
|
|
2067
|
+
if src:
|
|
2068
|
+
meta["source"] = str(src.get("label") or meta["ingest_id"])
|
|
2069
|
+
return meta
|
|
2070
|
+
|
|
2071
|
+
|
|
2072
|
+
def get_staging_inbox(project: str = "", limit: int = 20) -> dict:
|
|
2073
|
+
"""Retrieve un-distilled bullets grouped by staging source file.
|
|
2074
|
+
|
|
2075
|
+
limit=0 returns all groups with no cap on shown bullets.
|
|
2076
|
+
"""
|
|
2077
|
+
groups: List[dict] = []
|
|
2078
|
+
total = 0
|
|
2079
|
+
for path in _collect_staging_paths(project):
|
|
2080
|
+
if not path.is_file():
|
|
2081
|
+
continue
|
|
2082
|
+
meta = _staging_file_meta(path)
|
|
2083
|
+
seen_in_file: set[str] = set()
|
|
2084
|
+
bullets: List[dict] = []
|
|
2085
|
+
for line in _read(path).splitlines():
|
|
2086
|
+
stripped = line.strip()
|
|
2087
|
+
if not stripped.startswith(("- ", "* ")):
|
|
2088
|
+
continue
|
|
2089
|
+
text = stripped[2:].strip()
|
|
2090
|
+
if not text or text == "(none yet)":
|
|
2091
|
+
continue
|
|
2092
|
+
key = text.lower()
|
|
2093
|
+
if key in seen_in_file:
|
|
2094
|
+
continue
|
|
2095
|
+
seen_in_file.add(key)
|
|
2096
|
+
item = parse_staging_bullet(text)
|
|
2097
|
+
item["file"] = meta["file"]
|
|
2098
|
+
item["source_path"] = meta["file"]
|
|
2099
|
+
if meta["project"]:
|
|
2100
|
+
item["project"] = meta["project"]
|
|
2101
|
+
if meta["ingest_id"]:
|
|
2102
|
+
item["ingest_id"] = meta["ingest_id"]
|
|
2103
|
+
item["source"] = meta["source"]
|
|
2104
|
+
bullets.append(item)
|
|
2105
|
+
if bullets:
|
|
2106
|
+
groups.append(
|
|
2107
|
+
{
|
|
2108
|
+
"source": meta["source"],
|
|
2109
|
+
"ingest_id": meta["ingest_id"],
|
|
2110
|
+
"project": meta["project"],
|
|
2111
|
+
"file": meta["file"],
|
|
2112
|
+
"count": len(bullets),
|
|
2113
|
+
"bullets": bullets,
|
|
2114
|
+
}
|
|
2115
|
+
)
|
|
2116
|
+
total += len(bullets)
|
|
2117
|
+
|
|
2118
|
+
shown = total
|
|
2119
|
+
if limit > 0 and total > limit:
|
|
2120
|
+
capped_groups: List[dict] = []
|
|
2121
|
+
remaining = limit
|
|
2122
|
+
for group in groups:
|
|
2123
|
+
if remaining <= 0:
|
|
2124
|
+
break
|
|
2125
|
+
take = group["bullets"][:remaining]
|
|
2126
|
+
if not take:
|
|
2127
|
+
continue
|
|
2128
|
+
capped_groups.append(
|
|
2129
|
+
{
|
|
2130
|
+
**group,
|
|
2131
|
+
"bullets": take,
|
|
2132
|
+
"count": len(take),
|
|
2133
|
+
"truncated": len(take) < len(group["bullets"]),
|
|
2134
|
+
}
|
|
2135
|
+
)
|
|
2136
|
+
remaining -= len(take)
|
|
2137
|
+
groups = capped_groups
|
|
2138
|
+
shown = limit
|
|
2139
|
+
|
|
2140
|
+
return {"total": total, "shown": shown, "groups": groups}
|
|
2141
|
+
|
|
2142
|
+
|
|
2143
|
+
def count_staging_bullets(project: str = "") -> int:
|
|
2144
|
+
return int(get_staging_inbox(project=project, limit=0)["total"])
|
|
2145
|
+
|
|
2146
|
+
|
|
2147
|
+
def staging_status_summary() -> dict:
|
|
2148
|
+
from .ingest_config import load_ingest
|
|
2149
|
+
|
|
2150
|
+
inbox = get_staging_inbox(limit=0)
|
|
2151
|
+
cfg = load_ingest()
|
|
2152
|
+
threshold = max(0, int(cfg.get("staging_nag_threshold") or 50))
|
|
2153
|
+
total = int(inbox["total"])
|
|
2154
|
+
nag = ""
|
|
2155
|
+
if threshold > 0 and total >= threshold:
|
|
2156
|
+
nag = (
|
|
2157
|
+
f"{total} staging bullets waiting — run memory-distill, "
|
|
2158
|
+
"MCP get_staging_inbox, or `python -m agent_memory distill`"
|
|
2159
|
+
)
|
|
2160
|
+
return {
|
|
2161
|
+
"bullet_count": total,
|
|
2162
|
+
"group_count": len(inbox["groups"]),
|
|
2163
|
+
"threshold": threshold,
|
|
2164
|
+
"nag": nag,
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
|
|
2168
|
+
def distill_batch(items: List[dict]) -> dict:
|
|
2169
|
+
"""Batch process staging bullets into typed memory or discard them.
|
|
2170
|
+
|
|
2171
|
+
Each item is a dict with:
|
|
2172
|
+
- bullet: str (required)
|
|
2173
|
+
- discard: bool (if True, removes from staging without adding to memory)
|
|
2174
|
+
- kind, name, project, collection: str (for add_memory)
|
|
2175
|
+
"""
|
|
2176
|
+
promoted = 0
|
|
2177
|
+
discarded = 0
|
|
2178
|
+
errors: List[str] = []
|
|
2179
|
+
for item in items:
|
|
2180
|
+
bullet = str(item.get("bullet") or "").strip()
|
|
2181
|
+
if not bullet:
|
|
2182
|
+
continue
|
|
2183
|
+
proj = str(item.get("project") or "")
|
|
2184
|
+
src_path = str(item.get("source_path") or item.get("file") or "")
|
|
2185
|
+
if item.get("discard"):
|
|
2186
|
+
if remove_staging_bullet(bullet, project=proj, source_path=src_path):
|
|
2187
|
+
discarded += 1
|
|
2188
|
+
continue
|
|
2189
|
+
kind = str(item.get("kind") or "")
|
|
2190
|
+
name = str(item.get("name") or "")
|
|
2191
|
+
coll = str(item.get("collection") or "")
|
|
2192
|
+
try:
|
|
2193
|
+
loc, rem = promote_bullet(
|
|
2194
|
+
bullet=bullet,
|
|
2195
|
+
kind=kind,
|
|
2196
|
+
name=name,
|
|
2197
|
+
project=proj,
|
|
2198
|
+
collection=coll,
|
|
2199
|
+
source_path=src_path,
|
|
2200
|
+
)
|
|
2201
|
+
promoted += 1
|
|
2202
|
+
except Exception as e:
|
|
2203
|
+
errors.append(f"{bullet[:30]}...: {e}")
|
|
2204
|
+
|
|
2205
|
+
remaining = count_staging_bullets()
|
|
2206
|
+
return {
|
|
2207
|
+
"promoted": promoted,
|
|
2208
|
+
"discarded": discarded,
|
|
2209
|
+
"remaining_staging_count": remaining,
|
|
2210
|
+
"errors": errors,
|
|
2211
|
+
}
|