ompa 0.1.0__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.
- ompa/__init__.py +44 -0
- ompa/classifier.py +268 -0
- ompa/cli.py +298 -0
- ompa/core.py +297 -0
- ompa/hooks.py +391 -0
- ompa/knowledge_graph.py +303 -0
- ompa/mcp_server.py +601 -0
- ompa/palace.py +279 -0
- ompa/semantic.py +270 -0
- ompa/vault.py +252 -0
- ompa-0.1.0.dist-info/METADATA +239 -0
- ompa-0.1.0.dist-info/RECORD +16 -0
- ompa-0.1.0.dist-info/WHEEL +5 -0
- ompa-0.1.0.dist-info/entry_points.txt +3 -0
- ompa-0.1.0.dist-info/licenses/LICENSE +21 -0
- ompa-0.1.0.dist-info/top_level.txt +1 -0
ompa/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OMPA — Obsidian-MemPalace-Agnostic
|
|
3
|
+
|
|
4
|
+
Universal AI agent memory layer.
|
|
5
|
+
Combines obsidian-mind vault conventions + MemPalace palace structure + temporal knowledge graph.
|
|
6
|
+
|
|
7
|
+
Works with OpenClaw, Claude Code, Codex, Gemini CLI, or any AI agent.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
from ompa import AgnosticObsidian
|
|
11
|
+
ao = AgnosticObsidian(vault_path="./workspace")
|
|
12
|
+
result = ao.session_start()
|
|
13
|
+
hint = ao.handle_message("We decided to go with Postgres")
|
|
14
|
+
ao.post_tool("write", {"file_path": "work/active/auth.md"})
|
|
15
|
+
ao.stop()
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
from .core import AgnosticObsidian
|
|
21
|
+
from .vault import Vault, Note, VaultConfig
|
|
22
|
+
from .palace import Palace
|
|
23
|
+
from .knowledge_graph import KnowledgeGraph
|
|
24
|
+
from .classifier import MessageClassifier, Classification, MessageType
|
|
25
|
+
from .hooks import HookManager, HookContext, HookResult, Hook
|
|
26
|
+
from .semantic import SemanticIndex, SearchResult
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"AgnosticObsidian",
|
|
30
|
+
"Vault",
|
|
31
|
+
"Note",
|
|
32
|
+
"VaultConfig",
|
|
33
|
+
"Palace",
|
|
34
|
+
"KnowledgeGraph",
|
|
35
|
+
"MessageClassifier",
|
|
36
|
+
"Classification",
|
|
37
|
+
"MessageType",
|
|
38
|
+
"HookManager",
|
|
39
|
+
"HookContext",
|
|
40
|
+
"HookResult",
|
|
41
|
+
"Hook",
|
|
42
|
+
"SemanticIndex",
|
|
43
|
+
"SearchResult",
|
|
44
|
+
]
|
ompa/classifier.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Message classification for routing and context injection.
|
|
3
|
+
Classifies user messages into categories and injects routing hints.
|
|
4
|
+
"""
|
|
5
|
+
import re
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MessageType(Enum):
|
|
11
|
+
DECISION = "decision"
|
|
12
|
+
INCIDENT = "incident"
|
|
13
|
+
WIN = "win"
|
|
14
|
+
ONE_ON_ONE = "one-on-one"
|
|
15
|
+
MEETING = "meeting"
|
|
16
|
+
PROJECT_UPDATE = "project-update"
|
|
17
|
+
PERSON_INFO = "person-info"
|
|
18
|
+
QUESTION = "question"
|
|
19
|
+
TASK = "task"
|
|
20
|
+
ARCHITECTURE = "architecture"
|
|
21
|
+
CODE = "code"
|
|
22
|
+
BRAIN_DUMP = "brain-dump"
|
|
23
|
+
WRAP_UP = "wrap-up"
|
|
24
|
+
STANDUP = "standup"
|
|
25
|
+
UNKNOWN = "unknown"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Classification:
|
|
30
|
+
message_type: MessageType
|
|
31
|
+
confidence: float
|
|
32
|
+
routing_hints: list[str]
|
|
33
|
+
suggested_folder: str
|
|
34
|
+
suggested_action: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MessageClassifier:
|
|
38
|
+
"""
|
|
39
|
+
Classifies user messages and provides routing guidance.
|
|
40
|
+
Inspired by obsidian-minds classify-message.py but framework-agnostic.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
# Regex patterns for classification
|
|
44
|
+
PATTERNS = {
|
|
45
|
+
MessageType.DECISION: [
|
|
46
|
+
r"\b(decided|decision|we chose|going with|settled on|agreed to)\b",
|
|
47
|
+
r"\b(defer|postpone|push to|revisit)\b.*\b(Q\d|quarter|sprint)\b",
|
|
48
|
+
r"(?:ADR|decision record)",
|
|
49
|
+
],
|
|
50
|
+
MessageType.INCIDENT: [
|
|
51
|
+
r"\b(incident|outage|bug|crash|failure|error|broken)\b",
|
|
52
|
+
r"\b(debug|root cause|RCA|mitigation|hotfix)\b",
|
|
53
|
+
r"(?:on-call|pagerduty|statuspage)",
|
|
54
|
+
],
|
|
55
|
+
MessageType.WIN: [
|
|
56
|
+
r"\b(won|praised|success|achieved|shipped|launched|deployed)\b",
|
|
57
|
+
r"\b(great work|nice job|excellent|well done)\b",
|
|
58
|
+
r"\b(milestone|feature complete|released)\b",
|
|
59
|
+
],
|
|
60
|
+
MessageType.ONE_ON_ONE: [
|
|
61
|
+
r"\b(1:1|one-on-one|1on1|check-in|sync)\b.*\b(manager|lead|peer|coworker)\b",
|
|
62
|
+
r"\b(feedback|concerns|growth|progress|career)\b",
|
|
63
|
+
r"\b(1-on-1|weekly sync|bias for action)\b",
|
|
64
|
+
],
|
|
65
|
+
MessageType.MEETING: [
|
|
66
|
+
r"\b(meeting|briefing|session|call)\b",
|
|
67
|
+
r"\b(agenda|notes|takeaways|action items)\b",
|
|
68
|
+
r"\b(prep for|preparing for|standup|retrospective)\b",
|
|
69
|
+
],
|
|
70
|
+
MessageType.PROJECT_UPDATE: [
|
|
71
|
+
r"\b(project|initiative|epic|feature|story|ticket)\b.*\b(update|progress|status|blocked)\b",
|
|
72
|
+
r"\b(working on|started|finished|continuing)\b",
|
|
73
|
+
r"\b(blocked on|waiting for|depends on)\b",
|
|
74
|
+
],
|
|
75
|
+
MessageType.PERSON_INFO: [
|
|
76
|
+
r"\b(teammate|coworker|peer|manager|lead|engineer)\b.*\b(joined|moved|left|new|role)\b",
|
|
77
|
+
r"\b(people|team|person)\b.*\b(update|change|info)\b",
|
|
78
|
+
r"\b(Sarah|John|Mike|Tom|Jane)\b", # Names as hints
|
|
79
|
+
],
|
|
80
|
+
MessageType.QUESTION: [
|
|
81
|
+
r"\b(how do|how can|what is|what are|why does|can we|should we)\b",
|
|
82
|
+
r"\?", # Question marks
|
|
83
|
+
r"\b(clarify|explain|help me understand)\b",
|
|
84
|
+
],
|
|
85
|
+
MessageType.TASK: [
|
|
86
|
+
r"\b(task|todo|action item|follow-up)\b",
|
|
87
|
+
r"\b(do this|handle|take care of|responsible for)\b",
|
|
88
|
+
r"\-\s*\[[x ]\]", # Checkbox syntax
|
|
89
|
+
],
|
|
90
|
+
MessageType.ARCHITECTURE: [
|
|
91
|
+
r"\b(architecture|design|system design| ADR |technical design)\b",
|
|
92
|
+
r"\b(api|service|microservice|backend|frontend|infrastructure)\b.*\b(design|decide|approach)\b",
|
|
93
|
+
r"\b(migration|refactor|deprecate|legacy)\b",
|
|
94
|
+
],
|
|
95
|
+
MessageType.CODE: [
|
|
96
|
+
r"\b(code|function|class|module|import|export)\b",
|
|
97
|
+
r"\b(python|rust|javascript|typescript|java|go)\b",
|
|
98
|
+
r"\b(bug fix|feature|PR|pull request|commit)\b",
|
|
99
|
+
],
|
|
100
|
+
MessageType.BRAIN_DUMP: [
|
|
101
|
+
r"\b(dump|stream of consciousness|random thoughts|everything on my mind)\b",
|
|
102
|
+
r"\b(btw|also|oh and|forgot to mention)\b",
|
|
103
|
+
],
|
|
104
|
+
MessageType.WRAP_UP: [
|
|
105
|
+
r"\b(wrap up|wrapping up|finish up|end session|done for today)\b",
|
|
106
|
+
],
|
|
107
|
+
MessageType.STANDUP: [
|
|
108
|
+
r"\b(standup|daily|start of day|morning kickoff)\b",
|
|
109
|
+
r"\b(start session|start work|starting)\b",
|
|
110
|
+
],
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# Routing hints per message type
|
|
114
|
+
ROUTING_HINTS = {
|
|
115
|
+
MessageType.DECISION: [
|
|
116
|
+
"This is a decision. Record it in brain/Key Decisions.md",
|
|
117
|
+
"Update relevant project notes with the decision",
|
|
118
|
+
"Add to Decision Log if formal ADR needed",
|
|
119
|
+
],
|
|
120
|
+
MessageType.INCIDENT: [
|
|
121
|
+
"Create incident note in work/incidents/",
|
|
122
|
+
"Run /om-incident-capture for structured capture",
|
|
123
|
+
"Update brain/Gotchas.md with lessons learned",
|
|
124
|
+
],
|
|
125
|
+
MessageType.WIN: [
|
|
126
|
+
"Add to perf/Brag Doc.md immediately",
|
|
127
|
+
"This is a win worth capturing for performance review",
|
|
128
|
+
"Update relevant competency notes with evidence",
|
|
129
|
+
],
|
|
130
|
+
MessageType.ONE_ON_ONE: [
|
|
131
|
+
"Create or update 1:1 note in work/1-1/",
|
|
132
|
+
"Run /om-prep-1on1 if preparing for upcoming 1:1",
|
|
133
|
+
],
|
|
134
|
+
MessageType.MEETING: [
|
|
135
|
+
"Run /om-meeting for structured meeting prep",
|
|
136
|
+
"Add to work/meetings/ inbox for later processing",
|
|
137
|
+
],
|
|
138
|
+
MessageType.PROJECT_UPDATE: [
|
|
139
|
+
"Update work/active/ project note",
|
|
140
|
+
"Check if project status has changed",
|
|
141
|
+
],
|
|
142
|
+
MessageType.PERSON_INFO: [
|
|
143
|
+
"Update org/people/ note for this person",
|
|
144
|
+
"Run /om-people-profiler for context",
|
|
145
|
+
],
|
|
146
|
+
MessageType.QUESTION: [
|
|
147
|
+
"Answer directly with available context",
|
|
148
|
+
"Consider searching vault for relevant knowledge first",
|
|
149
|
+
],
|
|
150
|
+
MessageType.TASK: [
|
|
151
|
+
"Add to task list / obsidian tasks",
|
|
152
|
+
"Track completion in relevant project note",
|
|
153
|
+
],
|
|
154
|
+
MessageType.ARCHITECTURE: [
|
|
155
|
+
"Consider creating ADR in work/active/",
|
|
156
|
+
"Update reference/architecture docs",
|
|
157
|
+
],
|
|
158
|
+
MessageType.CODE: [
|
|
159
|
+
"Focus on implementation details",
|
|
160
|
+
"Update relevant code documentation",
|
|
161
|
+
],
|
|
162
|
+
MessageType.BRAIN_DUMP: [
|
|
163
|
+
"Run /om-dump for freeform capture",
|
|
164
|
+
"Route to appropriate notes automatically",
|
|
165
|
+
],
|
|
166
|
+
MessageType.WRAP_UP: [
|
|
167
|
+
"Run /om-wrap-up for session review",
|
|
168
|
+
"Verify all notes have links",
|
|
169
|
+
"Update indexes before closing",
|
|
170
|
+
],
|
|
171
|
+
MessageType.STANDUP: [
|
|
172
|
+
"Run /om-standup for structured morning context",
|
|
173
|
+
"Read brain/North Star.md first",
|
|
174
|
+
],
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
# Suggested folder locations
|
|
178
|
+
FOLDER_MAP = {
|
|
179
|
+
MessageType.DECISION: "work/active/",
|
|
180
|
+
MessageType.INCIDENT: "work/incidents/",
|
|
181
|
+
MessageType.WIN: "perf/brag/",
|
|
182
|
+
MessageType.ONE_ON_ONE: "work/1-1/",
|
|
183
|
+
MessageType.MEETING: "work/meetings/",
|
|
184
|
+
MessageType.PROJECT_UPDATE: "work/active/",
|
|
185
|
+
MessageType.PERSON_INFO: "org/people/",
|
|
186
|
+
MessageType.QUESTION: "thinking/",
|
|
187
|
+
MessageType.TASK: "work/active/",
|
|
188
|
+
MessageType.ARCHITECTURE: "work/active/",
|
|
189
|
+
MessageType.CODE: "reference/",
|
|
190
|
+
MessageType.BRAIN_DUMP: "thinking/",
|
|
191
|
+
MessageType.WRAP_UP: "brain/",
|
|
192
|
+
MessageType.STANDUP: "brain/",
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
def classify(self, message: str) -> Classification:
|
|
196
|
+
"""
|
|
197
|
+
Classify a user message and return routing guidance.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
message: The user message text
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Classification with type, confidence, hints, and suggested actions
|
|
204
|
+
"""
|
|
205
|
+
message_lower = message.lower()
|
|
206
|
+
scores = {}
|
|
207
|
+
|
|
208
|
+
for msg_type, patterns in self.PATTERNS.items():
|
|
209
|
+
score = 0
|
|
210
|
+
for pattern in patterns:
|
|
211
|
+
if re.search(pattern, message_lower, re.IGNORECASE):
|
|
212
|
+
score += 1
|
|
213
|
+
if score > 0:
|
|
214
|
+
scores[msg_type] = score
|
|
215
|
+
|
|
216
|
+
if not scores:
|
|
217
|
+
return Classification(
|
|
218
|
+
message_type=MessageType.UNKNOWN,
|
|
219
|
+
confidence=0.0,
|
|
220
|
+
routing_hints=["Process as normal conversation"],
|
|
221
|
+
suggested_folder="thinking/",
|
|
222
|
+
suggested_action="Continue conversation normally"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# Get highest scoring type
|
|
226
|
+
best_type = max(scores, key=scores.get)
|
|
227
|
+
confidence = min(scores[best_type] / 3.0, 1.0) # Normalize to 0-1
|
|
228
|
+
|
|
229
|
+
# For short messages, reduce confidence
|
|
230
|
+
if len(message.split()) < 5:
|
|
231
|
+
confidence *= 0.7
|
|
232
|
+
|
|
233
|
+
return Classification(
|
|
234
|
+
message_type=best_type,
|
|
235
|
+
confidence=confidence,
|
|
236
|
+
routing_hints=self.ROUTING_HINTS.get(best_type, []),
|
|
237
|
+
suggested_folder=self.FOLDER_MAP.get(best_type, "thinking/"),
|
|
238
|
+
suggested_action=self._get_action(best_type)
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def _get_action(self, msg_type: MessageType) -> str:
|
|
242
|
+
"""Get the primary action for a message type."""
|
|
243
|
+
actions = {
|
|
244
|
+
MessageType.DECISION: "Record decision and update relevant project notes",
|
|
245
|
+
MessageType.INCIDENT: "Create incident note and capture details",
|
|
246
|
+
MessageType.WIN: "Add to Brag Doc and update competency evidence",
|
|
247
|
+
MessageType.ONE_ON_ONE: "Create or update 1:1 meeting note",
|
|
248
|
+
MessageType.MEETING: "Create meeting prep or capture notes",
|
|
249
|
+
MessageType.PROJECT_UPDATE: "Update project status in work/active/",
|
|
250
|
+
MessageType.PERSON_INFO: "Update person note in org/people/",
|
|
251
|
+
MessageType.QUESTION: "Search vault and answer from context",
|
|
252
|
+
MessageType.TASK: "Add to task list",
|
|
253
|
+
MessageType.ARCHITECTURE: "Consider creating ADR",
|
|
254
|
+
MessageType.CODE: "Focus on implementation",
|
|
255
|
+
MessageType.BRAIN_DUMP: "Run /om-dump to route content",
|
|
256
|
+
MessageType.WRAP_UP: "Run /om-wrap-up session checklist",
|
|
257
|
+
MessageType.STANDUP: "Run /om-standup for morning context",
|
|
258
|
+
MessageType.UNKNOWN: "Continue conversation",
|
|
259
|
+
}
|
|
260
|
+
return actions.get(msg_type, "Continue conversation")
|
|
261
|
+
|
|
262
|
+
def get_routing_hint(self, message: str) -> str:
|
|
263
|
+
"""
|
|
264
|
+
Get a single-line routing hint for a message.
|
|
265
|
+
Suitable for injecting into context.
|
|
266
|
+
"""
|
|
267
|
+
classification = self.classify(message)
|
|
268
|
+
return f"[{classification.message_type.value.upper()}] {classification.suggested_action}"
|
ompa/cli.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI for AgnosticObsidian.
|
|
3
|
+
Run with: ao <command> or ao-mcp <command>
|
|
4
|
+
"""
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from agnostic_obsidian import AgnosticObsidian, Palace, KnowledgeGraph
|
|
16
|
+
except ImportError:
|
|
17
|
+
# Development mode
|
|
18
|
+
from agnostic_obsidian import AgnosticObsidian, Palace, KnowledgeGraph
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(help="AgnosticObsidian — Universal AI agent memory layer")
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command()
|
|
25
|
+
def init(
|
|
26
|
+
vault_path: Path = Path("."),
|
|
27
|
+
):
|
|
28
|
+
"""Initialize vault + palace structure."""
|
|
29
|
+
from agnostic_obsidian import Vault
|
|
30
|
+
vault = Vault(vault_path)
|
|
31
|
+
stats = vault.get_stats()
|
|
32
|
+
ao = AgnosticObsidian(vault_path, enable_semantic=False)
|
|
33
|
+
palace_count = ao.palace_build()
|
|
34
|
+
console.print(f"[green]Initialized at {vault_path.absolute()}[/green]")
|
|
35
|
+
console.print(f" Notes: {stats['total_notes']}")
|
|
36
|
+
console.print(f" Brain notes: {stats['brain_notes']}")
|
|
37
|
+
console.print(f" Palace wings built: {palace_count}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.command()
|
|
41
|
+
def status(
|
|
42
|
+
vault_path: Path = Path("."),
|
|
43
|
+
):
|
|
44
|
+
"""Show vault + palace + KG overview."""
|
|
45
|
+
ao = AgnosticObsidian(vault_path, enable_semantic=False)
|
|
46
|
+
|
|
47
|
+
vault_stats = ao.get_stats()
|
|
48
|
+
palace_stats = ao.palace.stats()
|
|
49
|
+
kg_stats = ao.kg.stats()
|
|
50
|
+
|
|
51
|
+
console.print("[bold]Vault[/bold]")
|
|
52
|
+
console.print(f" Total notes: {vault_stats['total_notes']}")
|
|
53
|
+
console.print(f" Brain notes: {vault_stats['brain_notes']}")
|
|
54
|
+
console.print(f" Orphans: {vault_stats['orphans']}")
|
|
55
|
+
|
|
56
|
+
console.print("[bold]Palace[/bold]")
|
|
57
|
+
console.print(f" Wings: {palace_stats['wing_count']}")
|
|
58
|
+
console.print(f" Rooms: {palace_stats['room_count']}")
|
|
59
|
+
console.print(f" Drawers: {palace_stats['drawer_count']}")
|
|
60
|
+
console.print(f" Tunnels: {palace_stats['tunnel_count']}")
|
|
61
|
+
|
|
62
|
+
console.print("[bold]Knowledge Graph[/bold]")
|
|
63
|
+
console.print(f" Entities: {kg_stats['entity_count']}")
|
|
64
|
+
console.print(f" Triples: {kg_stats['triple_count']}")
|
|
65
|
+
console.print(f" Current facts: {kg_stats['current_facts']}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.command()
|
|
69
|
+
def session_start(
|
|
70
|
+
vault_path: Path = Path("."),
|
|
71
|
+
):
|
|
72
|
+
"""Run session start hook."""
|
|
73
|
+
ao = AgnosticObsidian(vault_path, enable_semantic=False)
|
|
74
|
+
result = ao.session_start()
|
|
75
|
+
console.print(result.output)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command()
|
|
79
|
+
def classify(
|
|
80
|
+
message: str,
|
|
81
|
+
vault_path: Path = Path("."),
|
|
82
|
+
):
|
|
83
|
+
"""Classify a message."""
|
|
84
|
+
ao = AgnosticObsidian(vault_path, enable_semantic=False)
|
|
85
|
+
c = ao.classify(message)
|
|
86
|
+
console.print(f"[bold]Type:[/bold] {c.message_type.value.upper()}")
|
|
87
|
+
console.print(f"[bold]Confidence:[/bold] {c.confidence:.0%}")
|
|
88
|
+
console.print(f"[bold]Action:[/bold] {c.suggested_action}")
|
|
89
|
+
if c.routing_hints:
|
|
90
|
+
console.print("[bold]Hints:[/bold]")
|
|
91
|
+
for hint in c.routing_hints:
|
|
92
|
+
console.print(f" - {hint}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command()
|
|
96
|
+
def search(
|
|
97
|
+
query: str,
|
|
98
|
+
vault_path: Path = Path("."),
|
|
99
|
+
limit: int = 5,
|
|
100
|
+
):
|
|
101
|
+
"""Search the vault semantically."""
|
|
102
|
+
ao = AgnosticObsidian(vault_path, enable_semantic=True)
|
|
103
|
+
results = ao.search(query, limit=limit)
|
|
104
|
+
|
|
105
|
+
table = Table(title=f"Search: {query}")
|
|
106
|
+
table.add_column("Score")
|
|
107
|
+
table.add_column("Type")
|
|
108
|
+
table.add_column("Path")
|
|
109
|
+
table.add_column("Excerpt")
|
|
110
|
+
|
|
111
|
+
for r in results:
|
|
112
|
+
excerpt = r.content_excerpt[:80] + "..." if len(r.content_excerpt) > 80 else r.content_excerpt
|
|
113
|
+
table.add_row(f"{r.score:.2f}", r.match_type, r.path, excerpt)
|
|
114
|
+
|
|
115
|
+
console.print(table)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@app.command()
|
|
119
|
+
def orphans(
|
|
120
|
+
vault_path: Path = Path("."),
|
|
121
|
+
):
|
|
122
|
+
"""Find orphan notes (no wikilinks)."""
|
|
123
|
+
ao = AgnosticObsidian(vault_path, enable_semantic=False)
|
|
124
|
+
orphan_notes = ao.find_orphans()
|
|
125
|
+
if not orphan_notes:
|
|
126
|
+
console.print("[green]No orphan notes found![/green]")
|
|
127
|
+
else:
|
|
128
|
+
console.print(f"[yellow]Found {len(orphan_notes)} orphan notes:[/yellow]")
|
|
129
|
+
for note in orphan_notes:
|
|
130
|
+
rel = note.path.relative_to(vault_path) if note.path.is_relative_to(vault_path) else note.path
|
|
131
|
+
console.print(f" - {rel}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@app.command()
|
|
135
|
+
def wrap_up(
|
|
136
|
+
vault_path: Path = Path("."),
|
|
137
|
+
):
|
|
138
|
+
"""Run wrap-up (stop) hook."""
|
|
139
|
+
ao = AgnosticObsidian(vault_path, enable_semantic=False)
|
|
140
|
+
result = ao.stop()
|
|
141
|
+
console.print(result.output)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@app.command()
|
|
145
|
+
def wings(
|
|
146
|
+
vault_path: Path = Path("."),
|
|
147
|
+
):
|
|
148
|
+
"""List palace wings."""
|
|
149
|
+
ao = AgnosticObsidian(vault_path, enable_semantic=False)
|
|
150
|
+
wing_list = ao.palace.list_wings()
|
|
151
|
+
|
|
152
|
+
table = Table(title="Palace Wings")
|
|
153
|
+
table.add_column("Name")
|
|
154
|
+
table.add_column("Type")
|
|
155
|
+
table.add_column("Keywords")
|
|
156
|
+
|
|
157
|
+
for w in wing_list:
|
|
158
|
+
table.add_row(w["name"], w["type"], ", ".join(w.get("keywords", [])))
|
|
159
|
+
|
|
160
|
+
console.print(table)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@app.command()
|
|
164
|
+
def rooms(
|
|
165
|
+
wing: str,
|
|
166
|
+
vault_path: Path = Path("."),
|
|
167
|
+
):
|
|
168
|
+
"""List rooms in a wing."""
|
|
169
|
+
ao = AgnosticObsidian(vault_path, enable_semantic=False)
|
|
170
|
+
room_list = ao.palace.list_rooms(wing)
|
|
171
|
+
if not room_list:
|
|
172
|
+
console.print(f"[yellow]No rooms found in wing '{wing}'[/yellow]")
|
|
173
|
+
else:
|
|
174
|
+
console.print(f"[bold]Rooms in {wing}:[/bold]")
|
|
175
|
+
for r in room_list:
|
|
176
|
+
drawers = ao.palace.get_drawers(wing, r)
|
|
177
|
+
console.print(f" - {r} ({len(drawers)} drawers)")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@app.command()
|
|
181
|
+
def tunnel(
|
|
182
|
+
wing_a: str,
|
|
183
|
+
wing_b: str,
|
|
184
|
+
vault_path: Path = Path("."),
|
|
185
|
+
):
|
|
186
|
+
"""Find tunnels between two wings."""
|
|
187
|
+
ao = AgnosticObsidian(vault_path, enable_semantic=False)
|
|
188
|
+
tunnels = ao.palace.find_tunnels(wing_a, wing_b)
|
|
189
|
+
if not tunnels:
|
|
190
|
+
console.print(f"[yellow]No tunnels between {wing_a} and {wing_b}[/yellow]")
|
|
191
|
+
else:
|
|
192
|
+
console.print(f"[bold]Tunnels between {wing_a} and {wing_b}:[/bold]")
|
|
193
|
+
for t in tunnels:
|
|
194
|
+
console.print(f" - {t['wing_a']}/{t['room']} <-> {t['wing_b']}/{t['room']}")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@app.command()
|
|
198
|
+
def kg_query(
|
|
199
|
+
entity: str,
|
|
200
|
+
as_of: str = None,
|
|
201
|
+
vault_path: Path = Path("."),
|
|
202
|
+
):
|
|
203
|
+
"""Query the knowledge graph."""
|
|
204
|
+
ao = AgnosticObsidian(vault_path, enable_semantic=False)
|
|
205
|
+
triples = ao.kg.query_entity(entity, as_of=as_of)
|
|
206
|
+
|
|
207
|
+
if not triples:
|
|
208
|
+
console.print(f"[yellow]No facts found for '{entity}'[/yellow]")
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
console.print(f"[bold]Facts about {entity}:[/bold]")
|
|
212
|
+
if as_of:
|
|
213
|
+
console.print(f" (as of {as_of})")
|
|
214
|
+
for t in triples:
|
|
215
|
+
validity = f"({t.valid_from}"
|
|
216
|
+
validity += f" to {t.valid_to}" if t.valid_to else ""
|
|
217
|
+
validity += ")"
|
|
218
|
+
console.print(f" {t.subject} --{t.predicate}--> {t.object} {validity}")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@app.command()
|
|
222
|
+
def kg_timeline(
|
|
223
|
+
entity: str,
|
|
224
|
+
vault_path: Path = Path("."),
|
|
225
|
+
):
|
|
226
|
+
"""Get entity timeline."""
|
|
227
|
+
ao = AgnosticObsidian(vault_path, enable_semantic=False)
|
|
228
|
+
timeline = ao.kg.timeline(entity)
|
|
229
|
+
|
|
230
|
+
if not timeline:
|
|
231
|
+
console.print(f"[yellow]No timeline for '{entity}'[/yellow]")
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
console.print(f"[bold]Timeline: {entity}[/bold]")
|
|
235
|
+
for event in timeline:
|
|
236
|
+
date_str = event["date"] or "(no date)"
|
|
237
|
+
console.print(f" [{date_str}] {event['label']}")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@app.command()
|
|
241
|
+
def kg_stats(
|
|
242
|
+
vault_path: Path = Path("."),
|
|
243
|
+
):
|
|
244
|
+
"""Show KG statistics."""
|
|
245
|
+
ao = AgnosticObsidian(vault_path, enable_semantic=False)
|
|
246
|
+
stats = ao.kg.stats()
|
|
247
|
+
console.print("[bold]Knowledge Graph Stats[/bold]")
|
|
248
|
+
for k, v in stats.items():
|
|
249
|
+
console.print(f" {k}: {v}")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@app.command()
|
|
253
|
+
def validate(
|
|
254
|
+
vault_path: Path = Path("."),
|
|
255
|
+
):
|
|
256
|
+
"""Validate all notes in the vault."""
|
|
257
|
+
ao = AgnosticObsidian(vault_path, enable_semantic=False)
|
|
258
|
+
from agnostic_obsidian import Vault
|
|
259
|
+
vault = Vault(vault_path)
|
|
260
|
+
notes = vault.list_notes()
|
|
261
|
+
|
|
262
|
+
total = 0
|
|
263
|
+
warnings_list = []
|
|
264
|
+
for note in notes:
|
|
265
|
+
result = ao.validate_write(str(note.path))
|
|
266
|
+
if result["warnings"]:
|
|
267
|
+
total += 1
|
|
268
|
+
for w in result["warnings"]:
|
|
269
|
+
rel = note.path.relative_to(vault_path) if note.path.is_relative_to(vault_path) else note.path
|
|
270
|
+
warnings_list.append(f" {rel}: {w}")
|
|
271
|
+
|
|
272
|
+
if warnings_list:
|
|
273
|
+
console.print(f"[yellow]Found issues in {total} notes:[/yellow]")
|
|
274
|
+
for w in warnings_list[:20]:
|
|
275
|
+
console.print(w)
|
|
276
|
+
if len(warnings_list) > 20:
|
|
277
|
+
console.print(f" ... and {len(warnings_list) - 20} more")
|
|
278
|
+
else:
|
|
279
|
+
console.print("[green]All notes valid![/green]")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@app.command()
|
|
283
|
+
def rebuild_index(
|
|
284
|
+
vault_path: Path = Path("."),
|
|
285
|
+
):
|
|
286
|
+
"""Rebuild the semantic search index."""
|
|
287
|
+
ao = AgnosticObsidian(vault_path, enable_semantic=True)
|
|
288
|
+
count = ao.rebuild_index()
|
|
289
|
+
console.print(f"[green]Indexed {count} files[/green]")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def main():
|
|
293
|
+
"""Main entry point."""
|
|
294
|
+
app()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
if __name__ == "__main__":
|
|
298
|
+
main()
|