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.
- adaptive_memory_engine-0.1.6.dist-info/METADATA +228 -0
- adaptive_memory_engine-0.1.6.dist-info/RECORD +72 -0
- adaptive_memory_engine-0.1.6.dist-info/WHEEL +4 -0
- adaptive_memory_engine-0.1.6.dist-info/entry_points.txt +3 -0
- adaptive_memory_engine-0.1.6.dist-info/licenses/LICENSE +21 -0
- ame/__init__.py +1 -0
- ame/agent/__init__.py +1 -0
- ame/agent/mcp.py +474 -0
- ame/agent/memory_api.py +141 -0
- ame/agent/results.py +30 -0
- ame/bronze/schema.py +17 -0
- ame/bronze/store.py +38 -0
- ame/cli/__init__.py +1 -0
- ame/cli/main.py +903 -0
- ame/connectors/base.py +30 -0
- ame/connectors/contract.py +199 -0
- ame/connectors/github.py +66 -0
- ame/connectors/google.py +464 -0
- ame/connectors/google_oauth.py +156 -0
- ame/connectors/jira.py +66 -0
- ame/connectors/json_helpers.py +43 -0
- ame/connectors/markdown.py +116 -0
- ame/connectors/notion.py +59 -0
- ame/connectors/oauth_callback.py +102 -0
- ame/connectors/oauth_provider.py +250 -0
- ame/connectors/obsidian.py +19 -0
- ame/connectors/router.py +155 -0
- ame/connectors/slack.py +66 -0
- ame/connectors/slack_oauth.py +417 -0
- ame/connectors/sync_history.py +73 -0
- ame/context_budget.py +106 -0
- ame/core/config.py +77 -0
- ame/core/corpus.py +17 -0
- ame/core/errors.py +18 -0
- ame/core/paths.py +111 -0
- ame/core/state.py +57 -0
- ame/export/obsidian.py +123 -0
- ame/gold/builder.py +300 -0
- ame/gold/ontology.py +80 -0
- ame/gold/resolver.py +91 -0
- ame/gold/schema.py +40 -0
- ame/gold/store.py +45 -0
- ame/hardware/profiler.py +85 -0
- ame/hardware/tier.py +27 -0
- ame/hermes/__init__.py +3 -0
- ame/hermes/memory.py +209 -0
- ame/models/download.py +243 -0
- ame/models/ollama.py +60 -0
- ame/models/registry.py +101 -0
- ame/models/router.py +22 -0
- ame/pipeline.py +155 -0
- ame/query/diff.py +40 -0
- ame/query/engine.py +919 -0
- ame/query/memory_os.py +313 -0
- ame/query/mql.py +84 -0
- ame/query/multihop.py +264 -0
- ame/query/result.py +20 -0
- ame/sdk.py +52 -0
- ame/security.py +145 -0
- ame/silver/extractor.py +414 -0
- ame/silver/llm_extractor.py +181 -0
- ame/silver/prompts.py +56 -0
- ame/silver/rationale.py +140 -0
- ame/silver/schema.py +51 -0
- ame/silver/store.py +59 -0
- ame/storage/custom_kg.py +33 -0
- ame/storage/lightrag_adapter.py +362 -0
- ame/validation/confidence.py +5 -0
- ame/validation/grounding.py +10 -0
- ame/validation/type_gate.py +22 -0
- ame/writeback.py +173 -0
- 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"]
|