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/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
+ }