adaptive-memory-engine 0.1.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. adaptive_memory_engine-0.1.6.dist-info/METADATA +228 -0
  2. adaptive_memory_engine-0.1.6.dist-info/RECORD +72 -0
  3. adaptive_memory_engine-0.1.6.dist-info/WHEEL +4 -0
  4. adaptive_memory_engine-0.1.6.dist-info/entry_points.txt +3 -0
  5. adaptive_memory_engine-0.1.6.dist-info/licenses/LICENSE +21 -0
  6. ame/__init__.py +1 -0
  7. ame/agent/__init__.py +1 -0
  8. ame/agent/mcp.py +474 -0
  9. ame/agent/memory_api.py +141 -0
  10. ame/agent/results.py +30 -0
  11. ame/bronze/schema.py +17 -0
  12. ame/bronze/store.py +38 -0
  13. ame/cli/__init__.py +1 -0
  14. ame/cli/main.py +903 -0
  15. ame/connectors/base.py +30 -0
  16. ame/connectors/contract.py +199 -0
  17. ame/connectors/github.py +66 -0
  18. ame/connectors/google.py +464 -0
  19. ame/connectors/google_oauth.py +156 -0
  20. ame/connectors/jira.py +66 -0
  21. ame/connectors/json_helpers.py +43 -0
  22. ame/connectors/markdown.py +116 -0
  23. ame/connectors/notion.py +59 -0
  24. ame/connectors/oauth_callback.py +102 -0
  25. ame/connectors/oauth_provider.py +250 -0
  26. ame/connectors/obsidian.py +19 -0
  27. ame/connectors/router.py +155 -0
  28. ame/connectors/slack.py +66 -0
  29. ame/connectors/slack_oauth.py +417 -0
  30. ame/connectors/sync_history.py +73 -0
  31. ame/context_budget.py +106 -0
  32. ame/core/config.py +77 -0
  33. ame/core/corpus.py +17 -0
  34. ame/core/errors.py +18 -0
  35. ame/core/paths.py +111 -0
  36. ame/core/state.py +57 -0
  37. ame/export/obsidian.py +123 -0
  38. ame/gold/builder.py +300 -0
  39. ame/gold/ontology.py +80 -0
  40. ame/gold/resolver.py +91 -0
  41. ame/gold/schema.py +40 -0
  42. ame/gold/store.py +45 -0
  43. ame/hardware/profiler.py +85 -0
  44. ame/hardware/tier.py +27 -0
  45. ame/hermes/__init__.py +3 -0
  46. ame/hermes/memory.py +209 -0
  47. ame/models/download.py +243 -0
  48. ame/models/ollama.py +60 -0
  49. ame/models/registry.py +101 -0
  50. ame/models/router.py +22 -0
  51. ame/pipeline.py +155 -0
  52. ame/query/diff.py +40 -0
  53. ame/query/engine.py +919 -0
  54. ame/query/memory_os.py +313 -0
  55. ame/query/mql.py +84 -0
  56. ame/query/multihop.py +264 -0
  57. ame/query/result.py +20 -0
  58. ame/sdk.py +52 -0
  59. ame/security.py +145 -0
  60. ame/silver/extractor.py +414 -0
  61. ame/silver/llm_extractor.py +181 -0
  62. ame/silver/prompts.py +56 -0
  63. ame/silver/rationale.py +140 -0
  64. ame/silver/schema.py +51 -0
  65. ame/silver/store.py +59 -0
  66. ame/storage/custom_kg.py +33 -0
  67. ame/storage/lightrag_adapter.py +362 -0
  68. ame/validation/confidence.py +5 -0
  69. ame/validation/grounding.py +10 -0
  70. ame/validation/type_gate.py +22 -0
  71. ame/writeback.py +173 -0
  72. memory/__init__.py +3 -0
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import uuid
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class ConnectorSyncRun(BaseModel):
13
+ id: str = Field(default_factory=lambda: f"sync_{uuid.uuid4().hex[:12]}")
14
+ corpus_id: str
15
+ connector: str
16
+ status: Literal["success", "failed"]
17
+ source: str | None = None
18
+ started_at: datetime
19
+ finished_at: datetime
20
+ counts: dict[str, int] = Field(default_factory=dict)
21
+ metadata: dict[str, str] = Field(default_factory=dict)
22
+ error: str | None = None
23
+
24
+
25
+ class ConnectorSyncStore:
26
+ def __init__(self, corpus_root: Path):
27
+ self.corpus_root = corpus_root
28
+ self.path = corpus_root / "connectors" / "sync_history.jsonl"
29
+
30
+ def record(
31
+ self,
32
+ *,
33
+ connector: str,
34
+ status: Literal["success", "failed"],
35
+ started_at: datetime,
36
+ source: str | None = None,
37
+ counts: dict[str, int] | None = None,
38
+ metadata: dict[str, str] | None = None,
39
+ error: str | None = None,
40
+ ) -> ConnectorSyncRun:
41
+ run = ConnectorSyncRun(
42
+ corpus_id=self.corpus_root.name,
43
+ connector=connector,
44
+ status=status,
45
+ source=source,
46
+ started_at=started_at,
47
+ finished_at=datetime.now(timezone.utc),
48
+ counts=counts or {},
49
+ metadata=metadata or {},
50
+ error=error,
51
+ )
52
+ self.path.parent.mkdir(parents=True, exist_ok=True)
53
+ with self.path.open("a", encoding="utf-8") as fh:
54
+ fh.write(run.model_dump_json() + "\n")
55
+ return run
56
+
57
+ def list(self, *, connector: str | None = None, limit: int | None = None) -> list[ConnectorSyncRun]:
58
+ if not self.path.exists():
59
+ return []
60
+ rows: list[ConnectorSyncRun] = []
61
+ for line in self.path.read_text(encoding="utf-8").splitlines():
62
+ if not line.strip():
63
+ continue
64
+ row = ConnectorSyncRun.model_validate(json.loads(line))
65
+ if connector and row.connector != connector:
66
+ continue
67
+ rows.append(row)
68
+ rows.sort(key=lambda run: run.started_at, reverse=True)
69
+ return rows[:limit] if limit else rows
70
+
71
+ def latest(self, *, connector: str | None = None) -> ConnectorSyncRun | None:
72
+ rows = self.list(connector=connector, limit=1)
73
+ return rows[0] if rows else None
ame/context_budget.py ADDED
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import re
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class ContextItem(BaseModel):
11
+ source_id: str
12
+ content: str
13
+ priority: float = 0.5
14
+ metadata: dict[str, Any] = Field(default_factory=dict)
15
+
16
+
17
+ class OptimizedContextItem(BaseModel):
18
+ source_id: str
19
+ content: str
20
+ original_hash: str
21
+ compression: str = "none"
22
+ metadata: dict[str, Any] = Field(default_factory=dict)
23
+
24
+
25
+ class ContextBudgetResult(BaseModel):
26
+ budget_chars: int
27
+ original_chars: int
28
+ optimized_chars: int
29
+ items: list[OptimizedContextItem] = Field(default_factory=list)
30
+ archive: dict[str, str] = Field(default_factory=dict)
31
+
32
+
33
+ class ContextBudgetOptimizer:
34
+ def optimize(self, items: list[ContextItem], budget_chars: int) -> ContextBudgetResult:
35
+ if budget_chars < 1:
36
+ raise ValueError("budget_chars must be at least 1")
37
+ ordered = sorted(enumerate(items), key=lambda pair: (-pair[1].priority, pair[0]))
38
+ original_chars = sum(len(item.content) for item in items)
39
+ optimized: list[OptimizedContextItem] = []
40
+ archive: dict[str, str] = {}
41
+ remaining = budget_chars
42
+
43
+ for _index, item in ordered:
44
+ original_hash = _hash(item.content)
45
+ if remaining <= 0 and optimized:
46
+ archive[original_hash] = item.content
47
+ continue
48
+ if len(item.content) <= remaining:
49
+ content = item.content
50
+ compression = "none"
51
+ else:
52
+ content = _compress(item.content, max(1, remaining))
53
+ compression = "extractive"
54
+ archive[original_hash] = item.content
55
+ optimized.append(
56
+ OptimizedContextItem(
57
+ source_id=item.source_id,
58
+ content=content,
59
+ original_hash=original_hash,
60
+ compression=compression,
61
+ metadata=item.metadata,
62
+ )
63
+ )
64
+ remaining = max(0, remaining - len(content))
65
+
66
+ optimized_chars = sum(len(item.content) for item in optimized)
67
+ return ContextBudgetResult(
68
+ budget_chars=budget_chars,
69
+ original_chars=original_chars,
70
+ optimized_chars=optimized_chars,
71
+ items=optimized,
72
+ archive=archive,
73
+ )
74
+
75
+ def restore(self, result: ContextBudgetResult) -> list[ContextItem]:
76
+ restored: list[ContextItem] = []
77
+ for item in result.items:
78
+ restored.append(
79
+ ContextItem(
80
+ source_id=item.source_id,
81
+ content=result.archive.get(item.original_hash, item.content),
82
+ metadata=item.metadata,
83
+ )
84
+ )
85
+ return restored
86
+
87
+
88
+ def _compress(content: str, limit: int) -> str:
89
+ lines = [line.strip() for line in content.splitlines() if line.strip()]
90
+ key_lines = [
91
+ line
92
+ for line in lines
93
+ if re.search(r"\b(decision|rationale|because|next action|todo|blocker|결정|근거|이유|할 일|차단)\b", line, re.I)
94
+ ]
95
+ selected = []
96
+ for line in lines[:2] + key_lines:
97
+ if line not in selected:
98
+ selected.append(line)
99
+ text = "\n".join(selected) if selected else content.strip()
100
+ if len(text) <= limit:
101
+ return text
102
+ return text[: max(0, limit - 1)].rstrip() + "…"
103
+
104
+
105
+ def _hash(content: str) -> str:
106
+ return hashlib.sha256(content.encode("utf-8")).hexdigest()
ame/core/config.py ADDED
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import tomllib
4
+ from pathlib import Path
5
+ from typing import Any, Literal
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ from ame.core.paths import ame_home
10
+
11
+
12
+ class EngineConfig(BaseModel):
13
+ confidence_threshold: float = 0.7
14
+
15
+
16
+ class StorageConfig(BaseModel):
17
+ home: str = "~/Library/Application Support/ame"
18
+
19
+
20
+ class LightRagConfig(BaseModel):
21
+ backend: Literal["auto", "filesystem", "core"] = "auto"
22
+ query_mode: str = "hybrid"
23
+ ollama_host: str = "http://127.0.0.1:11434"
24
+ llm_model: str = "qwen3:8b"
25
+ embedding_model: str = "nomic-embed-text"
26
+ embedding_dim: int = 768
27
+ max_token_size: int = 8192
28
+
29
+
30
+ class SlackConfig(BaseModel):
31
+ client_id: str = ""
32
+ client_secret: str = ""
33
+ redirect_uri: str = "http://localhost:8765/slack/oauth/callback"
34
+ scopes: list[str] = Field(
35
+ default_factory=lambda: [
36
+ "channels:read",
37
+ "channels:history",
38
+ "groups:read",
39
+ "groups:history",
40
+ ]
41
+ )
42
+
43
+
44
+ class GoogleConfig(BaseModel):
45
+ client_id: str = ""
46
+ client_secret: str = ""
47
+ redirect_uri: str = "http://localhost:8765/google/oauth/callback"
48
+ scopes: list[str] = Field(
49
+ default_factory=lambda: [
50
+ "https://www.googleapis.com/auth/drive.readonly",
51
+ "https://www.googleapis.com/auth/gmail.readonly",
52
+ "https://www.googleapis.com/auth/calendar.readonly",
53
+ "https://www.googleapis.com/auth/spreadsheets.readonly",
54
+ ]
55
+ )
56
+
57
+
58
+ class SecurityConfig(BaseModel):
59
+ token_backend: Literal["file", "keychain", "auto"] = "file"
60
+ pii_redaction: Literal["off", "metadata", "content"] = "off"
61
+
62
+
63
+ class AmeConfig(BaseModel):
64
+ engine: EngineConfig = Field(default_factory=EngineConfig)
65
+ storage: StorageConfig = Field(default_factory=StorageConfig)
66
+ lightrag: LightRagConfig = Field(default_factory=LightRagConfig)
67
+ slack: SlackConfig = Field(default_factory=SlackConfig)
68
+ google: GoogleConfig = Field(default_factory=GoogleConfig)
69
+ security: SecurityConfig = Field(default_factory=SecurityConfig)
70
+
71
+
72
+ def load_config(path: Path | None = None) -> AmeConfig:
73
+ config_path = path or ame_home() / "config.toml"
74
+ if not config_path.exists():
75
+ return AmeConfig()
76
+ data: dict[str, Any] = tomllib.loads(config_path.read_text(encoding="utf-8"))
77
+ return AmeConfig.model_validate(data)
ame/core/corpus.py ADDED
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from ame.core.errors import CorpusNotFoundError
6
+ from ame.core.paths import corpus_path, ensure_corpus_layout
7
+
8
+
9
+ def create_corpus(corpus_id: str) -> Path:
10
+ return ensure_corpus_layout(corpus_id)
11
+
12
+
13
+ def require_corpus(corpus_id: str) -> Path:
14
+ root = corpus_path(corpus_id)
15
+ if not root.exists():
16
+ raise CorpusNotFoundError(f"Corpus does not exist: {corpus_id}")
17
+ return root
ame/core/errors.py ADDED
@@ -0,0 +1,18 @@
1
+ class AmeError(Exception):
2
+ """Base error for Adaptive Memory Engine."""
3
+
4
+
5
+ class CorpusNotFoundError(AmeError):
6
+ pass
7
+
8
+
9
+ class UnsupportedHardwareError(AmeError):
10
+ pass
11
+
12
+
13
+ class LlmClientError(AmeError):
14
+ pass
15
+
16
+
17
+ class LightRagBackendError(AmeError):
18
+ pass
ame/core/paths.py ADDED
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import platform
5
+ from pathlib import Path
6
+
7
+ from ame.gold.ontology import write_base_ontology
8
+ from ame.models.registry import ModelRegistry
9
+ from ame.security import ensure_private_dir, ensure_private_file
10
+
11
+
12
+ def ame_home() -> Path:
13
+ override = os.environ.get("AME_HOME")
14
+ if override:
15
+ return Path(override).expanduser().resolve()
16
+ return default_ame_home()
17
+
18
+
19
+ def default_ame_home() -> Path:
20
+ system = platform.system()
21
+ if system == "Darwin":
22
+ return Path("~/Library/Application Support/ame").expanduser()
23
+ if system == "Windows":
24
+ base = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA")
25
+ if base:
26
+ return Path(base).expanduser() / "AdaptiveMemoryEngine"
27
+ return Path.home() / "AppData" / "Local" / "AdaptiveMemoryEngine"
28
+ xdg_data_home = os.environ.get("XDG_DATA_HOME")
29
+ if xdg_data_home:
30
+ return Path(xdg_data_home).expanduser() / "ame"
31
+ return Path("~/.local/share/ame").expanduser()
32
+
33
+
34
+ def corpus_path(corpus_id: str) -> Path:
35
+ return ame_home() / "corpora" / corpus_id
36
+
37
+
38
+ def ensure_runtime_layout() -> Path:
39
+ home = ame_home()
40
+ ensure_private_dir(home)
41
+ (home / "models").mkdir(parents=True, exist_ok=True)
42
+ (home / "corpora").mkdir(parents=True, exist_ok=True)
43
+ ensure_private_dir(home / "tokens")
44
+ ModelRegistry().write_cache(home / "registry.cache.yaml")
45
+ config = home / "config.toml"
46
+ if not config.exists():
47
+ config.write_text(
48
+ "\n".join(
49
+ [
50
+ "[engine]",
51
+ "confidence_threshold = 0.7",
52
+ "",
53
+ "[lightrag]",
54
+ 'backend = "auto"',
55
+ 'query_mode = "hybrid"',
56
+ 'ollama_host = "http://127.0.0.1:11434"',
57
+ 'llm_model = "qwen3:8b"',
58
+ 'embedding_model = "nomic-embed-text"',
59
+ "embedding_dim = 768",
60
+ "max_token_size = 8192",
61
+ "",
62
+ "[slack]",
63
+ 'client_id = ""',
64
+ 'client_secret = ""',
65
+ 'redirect_uri = "http://localhost:8765/slack/oauth/callback"',
66
+ 'scopes = ["channels:read", "channels:history", "groups:read", "groups:history"]',
67
+ "",
68
+ "[google]",
69
+ 'client_id = ""',
70
+ 'client_secret = ""',
71
+ 'redirect_uri = "http://localhost:8765/google/oauth/callback"',
72
+ 'scopes = [',
73
+ ' "https://www.googleapis.com/auth/drive.readonly",',
74
+ ' "https://www.googleapis.com/auth/gmail.readonly",',
75
+ ' "https://www.googleapis.com/auth/calendar.readonly",',
76
+ ' "https://www.googleapis.com/auth/spreadsheets.readonly",',
77
+ "]",
78
+ "",
79
+ "[security]",
80
+ 'token_backend = "file"',
81
+ 'pii_redaction = "off"',
82
+ "",
83
+ ]
84
+ ),
85
+ encoding="utf-8",
86
+ )
87
+ ensure_private_file(config)
88
+ return home
89
+
90
+
91
+ def ensure_corpus_layout(corpus_id: str) -> Path:
92
+ root = corpus_path(corpus_id)
93
+ for rel in [
94
+ "bronze/documents",
95
+ "silver",
96
+ "gold",
97
+ "ontology",
98
+ "store/lightrag",
99
+ "exports/obsidian",
100
+ "connectors",
101
+ "imports",
102
+ ]:
103
+ (root / rel).mkdir(parents=True, exist_ok=True)
104
+ corpus_file = root / "corpus.toml"
105
+ if not corpus_file.exists():
106
+ corpus_file.write_text(f'id = "{corpus_id}"\n', encoding="utf-8")
107
+ write_base_ontology(root / "ontology" / "base.yaml")
108
+ state_db = root / "state.db"
109
+ if not state_db.exists():
110
+ state_db.write_text("", encoding="utf-8")
111
+ return root
ame/core/state.py ADDED
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from pathlib import Path
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class IngestedDocumentState(BaseModel):
10
+ id: str
11
+ source_id: str
12
+ content_hash: str
13
+
14
+
15
+ class CorpusState(BaseModel):
16
+ corpus_id: str
17
+ last_ingest_at: datetime | None = None
18
+ last_source_path: str | None = None
19
+ last_mode: str | None = None
20
+ documents: list[IngestedDocumentState] = Field(default_factory=list)
21
+ counts: dict[str, int] = Field(default_factory=dict)
22
+
23
+
24
+ class CorpusStateStore:
25
+ def __init__(self, corpus_root: Path):
26
+ self.path = corpus_root / "state.db"
27
+ self.legacy_path = corpus_root / "state.json"
28
+ self.corpus_id = corpus_root.name
29
+
30
+ def read(self) -> CorpusState:
31
+ path = self.path if self.path.exists() and self.path.read_text(encoding="utf-8").strip() else self.legacy_path
32
+ if not path.exists():
33
+ return CorpusState(corpus_id=self.corpus_id)
34
+ text = path.read_text(encoding="utf-8").strip()
35
+ if not text:
36
+ return CorpusState(corpus_id=self.corpus_id)
37
+ return CorpusState.model_validate_json(text)
38
+
39
+ def record_ingest(
40
+ self,
41
+ source_path: Path,
42
+ mode: str,
43
+ documents: list[IngestedDocumentState],
44
+ counts: dict[str, int],
45
+ ) -> CorpusState:
46
+ state = CorpusState(
47
+ corpus_id=self.corpus_id,
48
+ last_ingest_at=datetime.now(timezone.utc),
49
+ last_source_path=str(source_path),
50
+ last_mode=mode,
51
+ documents=documents,
52
+ counts=counts,
53
+ )
54
+ payload = state.model_dump_json(indent=2)
55
+ self.path.write_text(payload, encoding="utf-8")
56
+ self.legacy_path.write_text(payload, encoding="utf-8")
57
+ return state
ame/export/obsidian.py ADDED
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from ame.gold.schema import GoldEdge, GoldNode, GoldTimelineEvent
6
+ from ame.gold.store import GoldStore
7
+
8
+
9
+ class ObsidianExporter:
10
+ def __init__(self, gold: GoldStore):
11
+ self.gold = gold
12
+
13
+ def export(self, output_dir: Path) -> int:
14
+ output_dir.mkdir(parents=True, exist_ok=True)
15
+ nodes = self.gold.nodes()
16
+ edges = self.gold.edges()
17
+ timeline_by_title = {event.title.casefold(): event for event in self.gold.timeline()}
18
+ count = 0
19
+ for node in nodes:
20
+ path = self._node_path(output_dir, node)
21
+ path.parent.mkdir(parents=True, exist_ok=True)
22
+ event = timeline_by_title.get(node.name.casefold()) if node.type == "Decision" else None
23
+ note = self._decision_note(node, event, edges) if event else self._node_note(node, edges)
24
+ path.write_text(note, encoding="utf-8")
25
+ count += 1
26
+ (output_dir / "Index.md").write_text(self._index_note(nodes), encoding="utf-8")
27
+ count += 1
28
+ return count
29
+
30
+ def _node_note(self, node: GoldNode, edges: list[GoldEdge]) -> str:
31
+ lines = [
32
+ "---",
33
+ f"type: {node.type}",
34
+ f"canonical_name: {node.canonical_name}",
35
+ "---",
36
+ "",
37
+ f"# {node.name}",
38
+ "",
39
+ "## Relations",
40
+ *self._relation_lines(node, edges),
41
+ "",
42
+ "## Sources",
43
+ *[f"- {source_id}" for source_id in node.source_ids],
44
+ "",
45
+ ]
46
+ return "\n".join(lines)
47
+
48
+ def _decision_note(self, node: GoldNode, event: GoldTimelineEvent, edges: list[GoldEdge]) -> str:
49
+ project = event.project or self._edge_target(node.name, "MADE_IN", edges)
50
+ tools = [edge.target for edge in edges if edge.source == node.name and edge.relation == "USES"]
51
+ related_decisions = [edge.target for edge in edges if edge.source == node.name and edge.relation == "SUPERSEDES"]
52
+ lines = [
53
+ "---",
54
+ "type: Decision",
55
+ f"canonical_name: {node.canonical_name}",
56
+ f"status: {event.status or 'unknown'}",
57
+ "---",
58
+ "",
59
+ f"# {node.name}",
60
+ "",
61
+ f"Status: {event.status or 'unknown'}",
62
+ f"Project: {self._link('Project', project) if project else 'n/a'}",
63
+ "",
64
+ "## Summary",
65
+ "",
66
+ event.rationale or node.name,
67
+ "",
68
+ "## Related Tools",
69
+ *[f"- {self._link('Tool', tool)}" for tool in tools],
70
+ "",
71
+ "## Sources",
72
+ *[f"- {source_id}" for source_id in node.source_ids],
73
+ "",
74
+ "## Related Decisions",
75
+ *[f"- {self._link('Decision', title)}" for title in related_decisions],
76
+ "",
77
+ "## Relations",
78
+ *self._relation_lines(node, edges),
79
+ "",
80
+ ]
81
+ return "\n".join(lines)
82
+
83
+ def _index_note(self, nodes: list[GoldNode]) -> str:
84
+ lines = ["# Memory Index", ""]
85
+ for node_type in sorted({node.type for node in nodes}):
86
+ lines.extend([f"## {self._folder_name(node_type)}", ""])
87
+ for node in sorted((node for node in nodes if node.type == node_type), key=lambda row: row.name.casefold()):
88
+ lines.append(f"- {self._link(node.type, node.name)}")
89
+ lines.append("")
90
+ return "\n".join(lines)
91
+
92
+ def _node_path(self, output_dir: Path, node: GoldNode) -> Path:
93
+ return output_dir / self._folder_name(node.type) / f"{self._safe_name(node.name)}.md"
94
+
95
+ def _folder_name(self, node_type: str) -> str:
96
+ return {
97
+ "Project": "Projects",
98
+ "Tool": "Tools",
99
+ "Decision": "Decisions",
100
+ "Person": "People",
101
+ "Concept": "Concepts",
102
+ }.get(node_type, f"{node_type}s")
103
+
104
+ def _link(self, node_type: str, name: str) -> str:
105
+ return f"[[{self._folder_name(node_type)}/{self._safe_name(name)}|{name}]]"
106
+
107
+ def _safe_name(self, name: str) -> str:
108
+ return name.replace("/", "-").replace(":", " -").strip()
109
+
110
+ def _edge_target(self, source: str, relation: str, edges: list[GoldEdge]) -> str | None:
111
+ for edge in edges:
112
+ if edge.source == source and edge.relation == relation:
113
+ return edge.target
114
+ return None
115
+
116
+ def _relation_lines(self, node: GoldNode, edges: list[GoldEdge]) -> list[str]:
117
+ lines: list[str] = []
118
+ for edge in edges:
119
+ if edge.source == node.name:
120
+ lines.append(f"- {edge.relation}: {edge.target}")
121
+ elif edge.target == node.name:
122
+ lines.append(f"- {edge.relation}: {edge.source}")
123
+ return lines or ["- n/a"]