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
ame/cli/main.py
ADDED
|
@@ -0,0 +1,903 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from ame.agent.mcp import BootstrapMcpToolbox, LocalMcpToolbox, McpStdioServer
|
|
14
|
+
from ame.agent.memory_api import AgentMemoryAPI
|
|
15
|
+
from ame.connectors.router import ConnectorRouter
|
|
16
|
+
from ame.connectors.google_oauth import (
|
|
17
|
+
GoogleOAuthClient,
|
|
18
|
+
GoogleOAuthConfig,
|
|
19
|
+
GoogleOAuthError,
|
|
20
|
+
GoogleTokenStore,
|
|
21
|
+
exchange_and_save_google_token,
|
|
22
|
+
)
|
|
23
|
+
from ame.connectors.oauth_callback import OAuthCallbackError, new_oauth_state, run_local_oauth_login
|
|
24
|
+
from ame.connectors.oauth_provider import (
|
|
25
|
+
ConnectedAppOAuthClient,
|
|
26
|
+
ConnectedAppOAuthError,
|
|
27
|
+
ConnectedAppTokenStore,
|
|
28
|
+
default_config,
|
|
29
|
+
exchange_and_save_connected_app_token,
|
|
30
|
+
provider_spec,
|
|
31
|
+
)
|
|
32
|
+
from ame.connectors.slack_oauth import (
|
|
33
|
+
SlackApiClient,
|
|
34
|
+
SlackOAuthClient,
|
|
35
|
+
SlackOAuthConfig,
|
|
36
|
+
SlackOAuthError,
|
|
37
|
+
SlackOAuthTransport,
|
|
38
|
+
SlackTokenStore,
|
|
39
|
+
exchange_and_save_slack_token,
|
|
40
|
+
)
|
|
41
|
+
from ame.connectors.sync_history import ConnectorSyncStore
|
|
42
|
+
from ame.core.config import load_config
|
|
43
|
+
from ame.core.errors import LightRagBackendError, LlmClientError
|
|
44
|
+
from ame.core.corpus import create_corpus, require_corpus
|
|
45
|
+
from ame.core.paths import ame_home, ensure_runtime_layout
|
|
46
|
+
from ame.core.state import CorpusStateStore
|
|
47
|
+
from ame.export.obsidian import ObsidianExporter
|
|
48
|
+
from ame.gold.store import GoldStore
|
|
49
|
+
from ame.hardware.profiler import HardwareProfiler
|
|
50
|
+
from ame.models.download import ModelDownloadError, OllamaModelInstaller
|
|
51
|
+
from ame.models.registry import ModelRegistry, load_default_registry
|
|
52
|
+
from ame.models.router import ModelRouter
|
|
53
|
+
from ame.pipeline import MemoryPipeline
|
|
54
|
+
from ame.security import redact_secrets
|
|
55
|
+
from ame.storage.lightrag_adapter import LightRagAdapter
|
|
56
|
+
|
|
57
|
+
app = typer.Typer(no_args_is_help=True)
|
|
58
|
+
export_app = typer.Typer(no_args_is_help=True)
|
|
59
|
+
lightrag_app = typer.Typer(no_args_is_help=True)
|
|
60
|
+
mcp_app = typer.Typer(no_args_is_help=True)
|
|
61
|
+
slack_app = typer.Typer(no_args_is_help=True)
|
|
62
|
+
google_app = typer.Typer(no_args_is_help=True)
|
|
63
|
+
models_app = typer.Typer(no_args_is_help=True)
|
|
64
|
+
connectors_app = typer.Typer(no_args_is_help=True)
|
|
65
|
+
app.add_typer(export_app, name="export")
|
|
66
|
+
app.add_typer(lightrag_app, name="lightrag")
|
|
67
|
+
app.add_typer(mcp_app, name="mcp")
|
|
68
|
+
app.add_typer(slack_app, name="slack")
|
|
69
|
+
app.add_typer(google_app, name="google")
|
|
70
|
+
app.add_typer(models_app, name="models")
|
|
71
|
+
app.add_typer(connectors_app, name="connectors")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.command()
|
|
75
|
+
def init() -> None:
|
|
76
|
+
home = ensure_runtime_layout()
|
|
77
|
+
typer.echo(f"AME_HOME initialized: {home}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.command()
|
|
81
|
+
def doctor() -> None:
|
|
82
|
+
home = ensure_runtime_layout()
|
|
83
|
+
config = load_config()
|
|
84
|
+
profile = HardwareProfiler().profile(home)
|
|
85
|
+
registry = _load_registry()
|
|
86
|
+
plan = ModelRouter(registry).plan(profile)
|
|
87
|
+
install_plan = OllamaModelInstaller(host=config.lightrag.ollama_host, allow_cli_list=True).install_plan(plan, profile)
|
|
88
|
+
connector_profiles = ConnectorRouter().profiles()
|
|
89
|
+
typer.echo(f"AME_HOME: {home}")
|
|
90
|
+
typer.echo("Runtime: local filesystem")
|
|
91
|
+
typer.echo("Connectors: " + ", ".join(connector.name for connector in connector_profiles))
|
|
92
|
+
typer.echo(f"Hardware: {profile.os} {profile.machine}, RAM {profile.total_ram_gb}GB, disk free {profile.disk_free_gb}GB")
|
|
93
|
+
if profile.disk_free_gb < 30:
|
|
94
|
+
typer.echo("Warning: disk free is below the 30GB local MVP recommendation.")
|
|
95
|
+
typer.echo(f"Tier: {profile.tier.value}")
|
|
96
|
+
typer.echo(f"Model mode: {plan.mode}")
|
|
97
|
+
typer.echo(f"Extract model: {plan.models.extract.model}")
|
|
98
|
+
typer.echo(f"Verify model: {plan.models.verify.model}")
|
|
99
|
+
typer.echo(f"Synthesize model: {plan.models.synthesize.model}")
|
|
100
|
+
typer.echo(f"Embedding model: {plan.models.embed.model}")
|
|
101
|
+
typer.echo(f"Token backend: {config.security.token_backend}")
|
|
102
|
+
typer.echo(f"PII redaction: {config.security.pii_redaction}")
|
|
103
|
+
typer.echo(f"Ollama installed: {profile.ollama_installed}")
|
|
104
|
+
typer.echo(f"Ollama model status source: {install_plan.installed_models_source or 'unavailable'}")
|
|
105
|
+
typer.echo(f"Installed local LLM models: {', '.join(install_plan.installed_models) or 'n/a'}")
|
|
106
|
+
typer.echo(f"Missing recommended local LLM models: {', '.join(install_plan.missing_models) or 'none'}")
|
|
107
|
+
typer.echo(
|
|
108
|
+
"Default AME usage is local-LLM Bronze/Silver/Gold build. "
|
|
109
|
+
"Use deterministic mode only as a lightweight fallback."
|
|
110
|
+
)
|
|
111
|
+
if install_plan.error:
|
|
112
|
+
typer.echo(f"Model status error: {install_plan.error}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@app.command()
|
|
116
|
+
def setup(
|
|
117
|
+
execute: bool = typer.Option(False, "--execute", help="Pull recommended local models with Ollama."),
|
|
118
|
+
) -> None:
|
|
119
|
+
home = ensure_runtime_layout()
|
|
120
|
+
config = load_config()
|
|
121
|
+
profile = HardwareProfiler().profile(home)
|
|
122
|
+
registry = _load_registry()
|
|
123
|
+
plan = ModelRouter(registry).plan(profile)
|
|
124
|
+
installer = OllamaModelInstaller(host=config.lightrag.ollama_host, allow_cli_list=True)
|
|
125
|
+
install_plan = installer.install_plan(plan, profile)
|
|
126
|
+
|
|
127
|
+
typer.echo(f"AME_HOME: {home}")
|
|
128
|
+
typer.echo(f"Hardware tier: {plan.tier}")
|
|
129
|
+
typer.echo(f"Extract model: {plan.models.extract.model}")
|
|
130
|
+
typer.echo(f"Verify model: {plan.models.verify.model}")
|
|
131
|
+
typer.echo(f"Synthesize model: {plan.models.synthesize.model}")
|
|
132
|
+
typer.echo(f"Embedding model: {plan.models.embed.model}")
|
|
133
|
+
|
|
134
|
+
if not profile.ollama_installed:
|
|
135
|
+
typer.echo("Ollama is not installed. Install Ollama first, then run `memory setup --execute`.")
|
|
136
|
+
raise typer.Exit(1)
|
|
137
|
+
|
|
138
|
+
if not install_plan.missing_models:
|
|
139
|
+
typer.echo("Recommended local models are already installed.")
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
if not execute:
|
|
143
|
+
typer.echo("Recommended model pull commands:")
|
|
144
|
+
for command in install_plan.pull_commands:
|
|
145
|
+
typer.echo(" ".join(command))
|
|
146
|
+
typer.echo("Run `memory setup --execute` to pull them.")
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
results = installer.pull(install_plan.missing_models, execute=True, installed=install_plan.installed_models)
|
|
150
|
+
typer.echo(json.dumps([result.model_dump(mode="json") for result in results], ensure_ascii=False, indent=2))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@app.command()
|
|
154
|
+
def create(corpus_id: str) -> None:
|
|
155
|
+
ensure_runtime_layout()
|
|
156
|
+
root = create_corpus(corpus_id)
|
|
157
|
+
typer.echo(f"Corpus ready: {root}")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@app.command()
|
|
161
|
+
def ingest(
|
|
162
|
+
corpus_id: str,
|
|
163
|
+
source_path: Path,
|
|
164
|
+
mode: Literal["deterministic", "llm"] = "deterministic",
|
|
165
|
+
profile: str | None = None,
|
|
166
|
+
) -> None:
|
|
167
|
+
try:
|
|
168
|
+
report = MemoryPipeline().ingest(corpus_id, source_path, mode=mode, profile=profile)
|
|
169
|
+
except LlmClientError as exc:
|
|
170
|
+
typer.echo(f"LLM extraction failed: {exc}", err=True)
|
|
171
|
+
raise typer.Exit(1) from exc
|
|
172
|
+
except LightRagBackendError as exc:
|
|
173
|
+
typer.echo(f"LightRAG adapter failed: {exc}", err=True)
|
|
174
|
+
raise typer.Exit(1) from exc
|
|
175
|
+
typer.echo(
|
|
176
|
+
f"Ingested {report.documents} document(s) in {report.mode} mode, "
|
|
177
|
+
f"{report.gold_nodes} node(s), {report.gold_edges} edge(s), "
|
|
178
|
+
f"{report.rejected} rejected item(s)."
|
|
179
|
+
)
|
|
180
|
+
typer.echo(f"LightRAG custom KG staged: {report.custom_kg_path}")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.command()
|
|
184
|
+
def load(
|
|
185
|
+
corpus_id: str,
|
|
186
|
+
source_path: Path,
|
|
187
|
+
mode: Literal["deterministic", "llm"] = "llm",
|
|
188
|
+
profile: str | None = None,
|
|
189
|
+
) -> None:
|
|
190
|
+
ensure_runtime_layout()
|
|
191
|
+
create_corpus(corpus_id)
|
|
192
|
+
try:
|
|
193
|
+
report = MemoryPipeline().ingest(corpus_id, source_path, mode=mode, profile=profile)
|
|
194
|
+
except LlmClientError as exc:
|
|
195
|
+
typer.echo(f"LLM extraction failed: {exc}", err=True)
|
|
196
|
+
raise typer.Exit(1) from exc
|
|
197
|
+
except LightRagBackendError as exc:
|
|
198
|
+
typer.echo(f"LightRAG adapter failed: {exc}", err=True)
|
|
199
|
+
raise typer.Exit(1) from exc
|
|
200
|
+
typer.echo(
|
|
201
|
+
f"Loaded {report.documents} document(s) into {corpus_id}, "
|
|
202
|
+
f"{report.gold_nodes} node(s), {report.gold_edges} edge(s), "
|
|
203
|
+
f"{report.rejected} rejected item(s)."
|
|
204
|
+
)
|
|
205
|
+
typer.echo(f"LightRAG custom KG staged: {report.custom_kg_path}")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@app.command()
|
|
209
|
+
def connect(
|
|
210
|
+
corpus_id: str | None = typer.Argument(None),
|
|
211
|
+
client: Literal["generic", "codex", "claude"] = "generic",
|
|
212
|
+
ame_home_path: Path | None = typer.Option(None, "--ame-home", help="AME_HOME to put in the MCP client env."),
|
|
213
|
+
include_path_env: bool = typer.Option(False, "--include-path-env", help="Add ame's install directory to PATH in the MCP config env."),
|
|
214
|
+
absolute_command: bool = typer.Option(False, "--absolute-command", help="Use the absolute ame executable path in the MCP config."),
|
|
215
|
+
) -> None:
|
|
216
|
+
args = ["mcp", "stdio"] if corpus_id is None else ["mcp", "stdio", corpus_id]
|
|
217
|
+
if corpus_id is not None:
|
|
218
|
+
require_corpus(corpus_id)
|
|
219
|
+
env: dict[str, str] = {}
|
|
220
|
+
if ame_home_path is not None:
|
|
221
|
+
env["AME_HOME"] = str(ame_home_path.expanduser().resolve())
|
|
222
|
+
elif os.environ.get("AME_HOME"):
|
|
223
|
+
env["AME_HOME"] = str(ame_home().expanduser().resolve())
|
|
224
|
+
if include_path_env and not absolute_command:
|
|
225
|
+
path_env = _ame_path_env()
|
|
226
|
+
if path_env:
|
|
227
|
+
env["PATH"] = path_env
|
|
228
|
+
server = {
|
|
229
|
+
"command": _ame_command() if absolute_command else "ame",
|
|
230
|
+
"args": args,
|
|
231
|
+
}
|
|
232
|
+
if env:
|
|
233
|
+
server["env"] = env
|
|
234
|
+
name = "adaptive-memory-engine"
|
|
235
|
+
if client == "generic":
|
|
236
|
+
payload = server
|
|
237
|
+
else:
|
|
238
|
+
payload = {"mcpServers": {name: server}}
|
|
239
|
+
typer.echo(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@app.command()
|
|
243
|
+
def query(corpus_id: str, question: str, mode: str = "hybrid") -> None:
|
|
244
|
+
root = require_corpus(corpus_id)
|
|
245
|
+
try:
|
|
246
|
+
result = asyncio.run(LightRagAdapter(root).query(question, mode=mode))
|
|
247
|
+
except LightRagBackendError as exc:
|
|
248
|
+
typer.echo(f"LightRAG adapter failed: {exc}", err=True)
|
|
249
|
+
raise typer.Exit(1) from exc
|
|
250
|
+
typer.echo(result.answer)
|
|
251
|
+
if result.sources:
|
|
252
|
+
typer.echo("")
|
|
253
|
+
typer.echo("Sources:")
|
|
254
|
+
for source in result.sources:
|
|
255
|
+
typer.echo(f"- {source.document} ({source.source_id})")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@app.command()
|
|
259
|
+
def chat(
|
|
260
|
+
corpus_id: str,
|
|
261
|
+
mode: str = typer.Option("hybrid", "--mode", help="LightRAG query mode."),
|
|
262
|
+
sources: bool = typer.Option(True, "--sources/--no-sources", help="Show sources after each answer."),
|
|
263
|
+
) -> None:
|
|
264
|
+
root = require_corpus(corpus_id)
|
|
265
|
+
typer.echo(f"AME chat: {corpus_id}")
|
|
266
|
+
typer.echo("Type /exit to quit, /help for commands.")
|
|
267
|
+
while True:
|
|
268
|
+
try:
|
|
269
|
+
question = input("ame> ").strip()
|
|
270
|
+
except (EOFError, KeyboardInterrupt):
|
|
271
|
+
typer.echo("")
|
|
272
|
+
typer.echo("bye")
|
|
273
|
+
return
|
|
274
|
+
if not question:
|
|
275
|
+
continue
|
|
276
|
+
if question in {"/exit", "/quit", "exit", "quit", "q"}:
|
|
277
|
+
typer.echo("bye")
|
|
278
|
+
return
|
|
279
|
+
if question == "/help":
|
|
280
|
+
typer.echo("/exit quit chat")
|
|
281
|
+
typer.echo("/help show commands")
|
|
282
|
+
continue
|
|
283
|
+
try:
|
|
284
|
+
result = asyncio.run(LightRagAdapter(root).query(question, mode=mode))
|
|
285
|
+
except LightRagBackendError as exc:
|
|
286
|
+
typer.echo(f"LightRAG adapter failed: {exc}", err=True)
|
|
287
|
+
continue
|
|
288
|
+
typer.echo(result.answer)
|
|
289
|
+
if sources and result.sources:
|
|
290
|
+
typer.echo("")
|
|
291
|
+
typer.echo("Sources:")
|
|
292
|
+
for source in result.sources:
|
|
293
|
+
typer.echo(f"- {source.document} ({source.source_id})")
|
|
294
|
+
typer.echo("")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@app.command()
|
|
298
|
+
def retrieve(corpus_id: str, query: str, k: int = 8) -> None:
|
|
299
|
+
root = require_corpus(corpus_id)
|
|
300
|
+
result = AgentMemoryAPI(root).retrieve(query, k=k)
|
|
301
|
+
typer.echo(json.dumps(result.model_dump(), ensure_ascii=False, indent=2))
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@app.command()
|
|
305
|
+
def graph(corpus_id: str, entity: str) -> None:
|
|
306
|
+
root = require_corpus(corpus_id)
|
|
307
|
+
result = AgentMemoryAPI(root).graph(entity)
|
|
308
|
+
typer.echo(json.dumps(result.model_dump(), ensure_ascii=False, indent=2))
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@app.command()
|
|
312
|
+
def decisions(
|
|
313
|
+
corpus_id: str,
|
|
314
|
+
project: str | None = None,
|
|
315
|
+
all: bool = typer.Option(False, "--all"),
|
|
316
|
+
date_from: str | None = typer.Option(None, "--from"),
|
|
317
|
+
date_to: str | None = typer.Option(None, "--to"),
|
|
318
|
+
) -> None:
|
|
319
|
+
root = require_corpus(corpus_id)
|
|
320
|
+
result = AgentMemoryAPI(root).decisions(project=project, current_only=not all, date_from=date_from, date_to=date_to)
|
|
321
|
+
typer.echo(json.dumps(result.model_dump(), ensure_ascii=False, indent=2))
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@app.command("write-decision")
|
|
325
|
+
def write_decision(
|
|
326
|
+
corpus_id: str,
|
|
327
|
+
title: str,
|
|
328
|
+
rationale: str,
|
|
329
|
+
project: str | None = None,
|
|
330
|
+
source: str = "writeback",
|
|
331
|
+
participants: list[str] = typer.Option([], "--participant"),
|
|
332
|
+
) -> None:
|
|
333
|
+
root = require_corpus(corpus_id)
|
|
334
|
+
result = AgentMemoryAPI(root).write_decision(
|
|
335
|
+
title=title,
|
|
336
|
+
rationale=rationale,
|
|
337
|
+
project=project,
|
|
338
|
+
participants=participants,
|
|
339
|
+
source=source,
|
|
340
|
+
)
|
|
341
|
+
typer.echo(json.dumps(result.model_dump(), ensure_ascii=False, indent=2))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@app.command("write-note")
|
|
345
|
+
def write_note(corpus_id: str, title: str, content: str) -> None:
|
|
346
|
+
root = require_corpus(corpus_id)
|
|
347
|
+
result = AgentMemoryAPI(root).write_note(title=title, content=content)
|
|
348
|
+
typer.echo(json.dumps(result.model_dump(), ensure_ascii=False, indent=2))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@app.command("sync-status")
|
|
352
|
+
def sync_status(
|
|
353
|
+
corpus_id: str,
|
|
354
|
+
connector: str | None = typer.Option(None, "--connector"),
|
|
355
|
+
limit: int = typer.Option(10, "--limit", min=1),
|
|
356
|
+
) -> None:
|
|
357
|
+
root = require_corpus(corpus_id)
|
|
358
|
+
runs = ConnectorSyncStore(root).list(connector=connector, limit=limit)
|
|
359
|
+
typer.echo(json.dumps([run.model_dump(mode="json") for run in runs], ensure_ascii=False, indent=2))
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@models_app.command("status")
|
|
363
|
+
def models_status() -> None:
|
|
364
|
+
profile = HardwareProfiler().profile(_profile_disk_path())
|
|
365
|
+
registry = _load_registry()
|
|
366
|
+
plan = ModelRouter(registry).plan(profile)
|
|
367
|
+
install_plan = OllamaModelInstaller(host=load_config().lightrag.ollama_host, allow_cli_list=True).install_plan(plan, profile)
|
|
368
|
+
typer.echo(
|
|
369
|
+
json.dumps(
|
|
370
|
+
{
|
|
371
|
+
"hardware": profile.model_dump(mode="json"),
|
|
372
|
+
"plan": plan.model_dump(mode="json"),
|
|
373
|
+
"install": install_plan.model_dump(mode="json"),
|
|
374
|
+
},
|
|
375
|
+
ensure_ascii=False,
|
|
376
|
+
indent=2,
|
|
377
|
+
)
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@models_app.command("pull")
|
|
382
|
+
def models_pull(
|
|
383
|
+
model: list[str] = typer.Option([], "--model", help="Model name to pull. Repeat for multiple models."),
|
|
384
|
+
all_models: bool = typer.Option(False, "--all", help="Plan every required Ollama model for this hardware tier."),
|
|
385
|
+
execute: bool = typer.Option(False, "--execute", help="Run ollama pull. Without this, only prints planned commands."),
|
|
386
|
+
) -> None:
|
|
387
|
+
profile = HardwareProfiler().profile(_profile_disk_path())
|
|
388
|
+
registry = _load_registry()
|
|
389
|
+
plan = ModelRouter(registry).plan(profile)
|
|
390
|
+
installer = OllamaModelInstaller(host=load_config().lightrag.ollama_host, allow_cli_list=True)
|
|
391
|
+
install_plan = installer.install_plan(plan, profile)
|
|
392
|
+
targets = model or (install_plan.required_models if all_models else install_plan.missing_models)
|
|
393
|
+
try:
|
|
394
|
+
results = installer.pull(targets, execute=execute, installed=install_plan.installed_models)
|
|
395
|
+
except ModelDownloadError as exc:
|
|
396
|
+
typer.echo(str(exc), err=True)
|
|
397
|
+
raise typer.Exit(1) from exc
|
|
398
|
+
typer.echo(json.dumps([result.model_dump(mode="json") for result in results], ensure_ascii=False, indent=2))
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@connectors_app.command("profiles")
|
|
402
|
+
def connector_profiles() -> None:
|
|
403
|
+
profiles = ConnectorRouter().profiles()
|
|
404
|
+
typer.echo(json.dumps([profile.model_dump(mode="json") for profile in profiles], ensure_ascii=False, indent=2))
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
@connectors_app.command("diff")
|
|
408
|
+
def connector_diff(
|
|
409
|
+
corpus_id: str,
|
|
410
|
+
source_path: Path,
|
|
411
|
+
profile: str = typer.Option("obsidian", "--profile"),
|
|
412
|
+
) -> None:
|
|
413
|
+
root = require_corpus(corpus_id)
|
|
414
|
+
runtime = ConnectorRouter().runtime(source_path, profile)
|
|
415
|
+
diff = runtime.diff(root.name, source_path)
|
|
416
|
+
payload = diff.model_dump(mode="json")
|
|
417
|
+
payload["counts"] = diff.counts
|
|
418
|
+
typer.echo(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@connectors_app.command("sync")
|
|
422
|
+
def connector_sync(
|
|
423
|
+
corpus_id: str,
|
|
424
|
+
source_path: Path,
|
|
425
|
+
profile: str = typer.Option("obsidian", "--profile"),
|
|
426
|
+
) -> None:
|
|
427
|
+
ensure_runtime_layout()
|
|
428
|
+
create_corpus(corpus_id)
|
|
429
|
+
runtime = ConnectorRouter().runtime(source_path, profile)
|
|
430
|
+
report = runtime.sync(corpus_id, source_path)
|
|
431
|
+
typer.echo(json.dumps(report.model_dump(mode="json"), ensure_ascii=False, indent=2))
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@connectors_app.command("login")
|
|
435
|
+
def connector_login(
|
|
436
|
+
provider: str,
|
|
437
|
+
account_id: str = typer.Option("default", "--account-id"),
|
|
438
|
+
client_id: str | None = typer.Option(None, "--client-id"),
|
|
439
|
+
client_secret: str | None = typer.Option(None, "--client-secret"),
|
|
440
|
+
redirect_uri: str | None = typer.Option(None, "--redirect-uri"),
|
|
441
|
+
scopes: str | None = typer.Option(None, "--scopes", help="Comma-separated OAuth scopes."),
|
|
442
|
+
open_browser: bool = typer.Option(True, "--open-browser/--no-browser"),
|
|
443
|
+
timeout: int = typer.Option(180, "--timeout", min=10),
|
|
444
|
+
) -> None:
|
|
445
|
+
try:
|
|
446
|
+
token = _connector_login(
|
|
447
|
+
provider,
|
|
448
|
+
account_id=account_id,
|
|
449
|
+
client_id=client_id,
|
|
450
|
+
client_secret=client_secret,
|
|
451
|
+
redirect_uri=redirect_uri,
|
|
452
|
+
scopes=scopes,
|
|
453
|
+
open_browser=open_browser,
|
|
454
|
+
timeout=timeout,
|
|
455
|
+
)
|
|
456
|
+
except (OAuthCallbackError, SlackOAuthError, GoogleOAuthError, ConnectedAppOAuthError) as exc:
|
|
457
|
+
typer.echo(str(exc), err=True)
|
|
458
|
+
raise typer.Exit(1) from exc
|
|
459
|
+
typer.echo(json.dumps(redact_secrets(token), ensure_ascii=False, indent=2))
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
@connectors_app.command("revoke-token")
|
|
463
|
+
def connector_revoke_token(
|
|
464
|
+
provider: str,
|
|
465
|
+
account_id: str = typer.Option("default", "--account-id"),
|
|
466
|
+
) -> None:
|
|
467
|
+
try:
|
|
468
|
+
normalized = provider.casefold().replace("_", "-")
|
|
469
|
+
if normalized == "google":
|
|
470
|
+
revoked = GoogleTokenStore(backend=_token_backend()).revoke(account_id)
|
|
471
|
+
elif normalized == "slack":
|
|
472
|
+
revoked = SlackTokenStore(backend=_token_backend()).revoke(account_id)
|
|
473
|
+
else:
|
|
474
|
+
revoked = ConnectedAppTokenStore(provider, backend=_token_backend()).revoke(account_id)
|
|
475
|
+
except (SlackOAuthError, GoogleOAuthError, ConnectedAppOAuthError) as exc:
|
|
476
|
+
typer.echo(str(exc), err=True)
|
|
477
|
+
raise typer.Exit(1) from exc
|
|
478
|
+
typer.echo(json.dumps({"provider": provider, "account_id": account_id, "revoked": revoked}, ensure_ascii=False, indent=2))
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
@connectors_app.command("token-status")
|
|
482
|
+
def connector_token_status(
|
|
483
|
+
provider: str,
|
|
484
|
+
account_id: str = typer.Option("default", "--account-id"),
|
|
485
|
+
) -> None:
|
|
486
|
+
try:
|
|
487
|
+
normalized = provider.casefold().replace("_", "-")
|
|
488
|
+
if normalized == "google":
|
|
489
|
+
token = GoogleTokenStore(backend=_token_backend()).load(account_id)
|
|
490
|
+
elif normalized == "slack":
|
|
491
|
+
token = SlackTokenStore(backend=_token_backend()).load(account_id)
|
|
492
|
+
else:
|
|
493
|
+
token = ConnectedAppTokenStore(provider, backend=_token_backend()).load(account_id)
|
|
494
|
+
except (SlackOAuthError, GoogleOAuthError, ConnectedAppOAuthError):
|
|
495
|
+
typer.echo(json.dumps({"provider": provider, "account_id": account_id, "connected": False}, ensure_ascii=False, indent=2))
|
|
496
|
+
return
|
|
497
|
+
typer.echo(
|
|
498
|
+
json.dumps(
|
|
499
|
+
redact_secrets({"provider": provider, "account_id": account_id, "connected": True, "token": token.model_dump(mode="json")}),
|
|
500
|
+
ensure_ascii=False,
|
|
501
|
+
indent=2,
|
|
502
|
+
)
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
@slack_app.command("auth-url")
|
|
507
|
+
def slack_auth_url(
|
|
508
|
+
state: str = "ame",
|
|
509
|
+
client_id: str | None = typer.Option(None, "--client-id"),
|
|
510
|
+
redirect_uri: str | None = typer.Option(None, "--redirect-uri"),
|
|
511
|
+
scopes: str | None = typer.Option(None, "--scopes", help="Comma-separated Slack OAuth scopes."),
|
|
512
|
+
) -> None:
|
|
513
|
+
ensure_runtime_layout()
|
|
514
|
+
config = _slack_config(client_id=client_id, redirect_uri=redirect_uri, scopes=scopes)
|
|
515
|
+
_require_option(config.client_id, "--client-id or [slack].client_id")
|
|
516
|
+
typer.echo(SlackOAuthClient(config).authorization_url(state))
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@slack_app.command("exchange-code")
|
|
520
|
+
def slack_exchange_code(
|
|
521
|
+
code: str,
|
|
522
|
+
client_id: str | None = typer.Option(None, "--client-id"),
|
|
523
|
+
client_secret: str | None = typer.Option(None, "--client-secret"),
|
|
524
|
+
redirect_uri: str | None = typer.Option(None, "--redirect-uri"),
|
|
525
|
+
scopes: str | None = typer.Option(None, "--scopes", help="Comma-separated Slack OAuth scopes."),
|
|
526
|
+
) -> None:
|
|
527
|
+
ensure_runtime_layout()
|
|
528
|
+
config = _slack_config(client_id=client_id, client_secret=client_secret, redirect_uri=redirect_uri, scopes=scopes)
|
|
529
|
+
_require_option(config.client_id, "--client-id or [slack].client_id")
|
|
530
|
+
_require_option(config.client_secret, "--client-secret or [slack].client_secret")
|
|
531
|
+
try:
|
|
532
|
+
token = exchange_and_save_slack_token(code, config, token_backend=_token_backend())
|
|
533
|
+
except SlackOAuthError as exc:
|
|
534
|
+
typer.echo(str(exc), err=True)
|
|
535
|
+
raise typer.Exit(1) from exc
|
|
536
|
+
typer.echo(json.dumps(redact_secrets(token.model_dump(mode="json")), ensure_ascii=False, indent=2))
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
@slack_app.command("login")
|
|
540
|
+
def slack_login(
|
|
541
|
+
team_id: str = typer.Option("default", "--team-id"),
|
|
542
|
+
client_id: str | None = typer.Option(None, "--client-id"),
|
|
543
|
+
client_secret: str | None = typer.Option(None, "--client-secret"),
|
|
544
|
+
redirect_uri: str | None = typer.Option(None, "--redirect-uri"),
|
|
545
|
+
scopes: str | None = typer.Option(None, "--scopes", help="Comma-separated Slack OAuth scopes."),
|
|
546
|
+
open_browser: bool = typer.Option(True, "--open-browser/--no-browser"),
|
|
547
|
+
timeout: int = typer.Option(180, "--timeout", min=10),
|
|
548
|
+
) -> None:
|
|
549
|
+
try:
|
|
550
|
+
token = _connector_login(
|
|
551
|
+
"slack",
|
|
552
|
+
account_id=team_id,
|
|
553
|
+
client_id=client_id,
|
|
554
|
+
client_secret=client_secret,
|
|
555
|
+
redirect_uri=redirect_uri,
|
|
556
|
+
scopes=scopes,
|
|
557
|
+
open_browser=open_browser,
|
|
558
|
+
timeout=timeout,
|
|
559
|
+
)
|
|
560
|
+
except (OAuthCallbackError, SlackOAuthError) as exc:
|
|
561
|
+
typer.echo(str(exc), err=True)
|
|
562
|
+
raise typer.Exit(1) from exc
|
|
563
|
+
typer.echo(json.dumps(redact_secrets(token), ensure_ascii=False, indent=2))
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
@slack_app.command("sync")
|
|
567
|
+
def slack_sync(
|
|
568
|
+
corpus_id: str,
|
|
569
|
+
team_id: str,
|
|
570
|
+
channels: list[str] = typer.Option([], "--channel", help="Channel id or name. Repeat for multiple channels."),
|
|
571
|
+
no_ingest: bool = typer.Option(False, "--no-ingest"),
|
|
572
|
+
) -> None:
|
|
573
|
+
ensure_runtime_layout()
|
|
574
|
+
create_corpus(corpus_id)
|
|
575
|
+
try:
|
|
576
|
+
token = SlackTokenStore(backend=_token_backend()).load(team_id)
|
|
577
|
+
report = SlackOAuthTransport(SlackApiClient(token)).sync(
|
|
578
|
+
corpus_id,
|
|
579
|
+
channels=channels or None,
|
|
580
|
+
ingest=not no_ingest,
|
|
581
|
+
)
|
|
582
|
+
except SlackOAuthError as exc:
|
|
583
|
+
typer.echo(str(exc), err=True)
|
|
584
|
+
raise typer.Exit(1) from exc
|
|
585
|
+
typer.echo(json.dumps(report.model_dump(mode="json"), ensure_ascii=False, indent=2))
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
@slack_app.command("revoke-token")
|
|
589
|
+
def slack_revoke_token(team_id: str) -> None:
|
|
590
|
+
revoked = SlackTokenStore(backend=_token_backend()).revoke(team_id)
|
|
591
|
+
typer.echo(json.dumps({"provider": "slack", "team_id": team_id, "revoked": revoked}, ensure_ascii=False, indent=2))
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
@google_app.command("auth-url")
|
|
595
|
+
def google_auth_url(
|
|
596
|
+
state: str = "ame",
|
|
597
|
+
client_id: str | None = typer.Option(None, "--client-id"),
|
|
598
|
+
redirect_uri: str | None = typer.Option(None, "--redirect-uri"),
|
|
599
|
+
scopes: str | None = typer.Option(None, "--scopes", help="Comma-separated Google OAuth scopes."),
|
|
600
|
+
) -> None:
|
|
601
|
+
ensure_runtime_layout()
|
|
602
|
+
config = _google_config(client_id=client_id, redirect_uri=redirect_uri, scopes=scopes)
|
|
603
|
+
_require_option(config.client_id, "--client-id or [google].client_id", provider="Google OAuth")
|
|
604
|
+
typer.echo(GoogleOAuthClient(config).authorization_url(state))
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
@google_app.command("exchange-code")
|
|
608
|
+
def google_exchange_code(
|
|
609
|
+
code: str,
|
|
610
|
+
account_id: str = typer.Option("default", "--account-id"),
|
|
611
|
+
client_id: str | None = typer.Option(None, "--client-id"),
|
|
612
|
+
client_secret: str | None = typer.Option(None, "--client-secret"),
|
|
613
|
+
redirect_uri: str | None = typer.Option(None, "--redirect-uri"),
|
|
614
|
+
scopes: str | None = typer.Option(None, "--scopes", help="Comma-separated Google OAuth scopes."),
|
|
615
|
+
) -> None:
|
|
616
|
+
ensure_runtime_layout()
|
|
617
|
+
config = _google_config(client_id=client_id, client_secret=client_secret, redirect_uri=redirect_uri, scopes=scopes)
|
|
618
|
+
_require_option(config.client_id, "--client-id or [google].client_id", provider="Google OAuth")
|
|
619
|
+
_require_option(config.client_secret, "--client-secret or [google].client_secret", provider="Google OAuth")
|
|
620
|
+
try:
|
|
621
|
+
token = exchange_and_save_google_token(code, config, account_id=account_id, token_backend=_token_backend())
|
|
622
|
+
except GoogleOAuthError as exc:
|
|
623
|
+
typer.echo(str(exc), err=True)
|
|
624
|
+
raise typer.Exit(1) from exc
|
|
625
|
+
typer.echo(json.dumps(redact_secrets(token.model_dump(mode="json")), ensure_ascii=False, indent=2))
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
@google_app.command("login")
|
|
629
|
+
def google_login(
|
|
630
|
+
account_id: str = typer.Option("default", "--account-id"),
|
|
631
|
+
client_id: str | None = typer.Option(None, "--client-id"),
|
|
632
|
+
client_secret: str | None = typer.Option(None, "--client-secret"),
|
|
633
|
+
redirect_uri: str | None = typer.Option(None, "--redirect-uri"),
|
|
634
|
+
scopes: str | None = typer.Option(None, "--scopes", help="Comma-separated Google OAuth scopes."),
|
|
635
|
+
open_browser: bool = typer.Option(True, "--open-browser/--no-browser"),
|
|
636
|
+
timeout: int = typer.Option(180, "--timeout", min=10),
|
|
637
|
+
) -> None:
|
|
638
|
+
try:
|
|
639
|
+
token = _connector_login(
|
|
640
|
+
"google",
|
|
641
|
+
account_id=account_id,
|
|
642
|
+
client_id=client_id,
|
|
643
|
+
client_secret=client_secret,
|
|
644
|
+
redirect_uri=redirect_uri,
|
|
645
|
+
scopes=scopes,
|
|
646
|
+
open_browser=open_browser,
|
|
647
|
+
timeout=timeout,
|
|
648
|
+
)
|
|
649
|
+
except (OAuthCallbackError, GoogleOAuthError) as exc:
|
|
650
|
+
typer.echo(str(exc), err=True)
|
|
651
|
+
raise typer.Exit(1) from exc
|
|
652
|
+
typer.echo(json.dumps(redact_secrets(token), ensure_ascii=False, indent=2))
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
@google_app.command("revoke-token")
|
|
656
|
+
def google_revoke_token(account_id: str = typer.Option("default", "--account-id")) -> None:
|
|
657
|
+
revoked = GoogleTokenStore(backend=_token_backend()).revoke(account_id)
|
|
658
|
+
typer.echo(json.dumps({"provider": "google", "account_id": account_id, "revoked": revoked}, ensure_ascii=False, indent=2))
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
@app.command()
|
|
662
|
+
def serve(corpus_id: str, mcp: bool = typer.Option(False, "--mcp")) -> None:
|
|
663
|
+
root = require_corpus(corpus_id)
|
|
664
|
+
if not mcp:
|
|
665
|
+
typer.echo("Only MCP stdio mode is available in this build. Use --mcp.")
|
|
666
|
+
raise typer.Exit(1)
|
|
667
|
+
McpStdioServer(root).run()
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
@app.command()
|
|
671
|
+
def stats(corpus_id: str) -> None:
|
|
672
|
+
root = require_corpus(corpus_id)
|
|
673
|
+
state = CorpusStateStore(root).read()
|
|
674
|
+
typer.echo(f"Corpus: {corpus_id}")
|
|
675
|
+
typer.echo(f"Last ingest: {state.last_ingest_at or 'never'}")
|
|
676
|
+
typer.echo(f"Mode: {state.last_mode or 'n/a'}")
|
|
677
|
+
for key in ["documents", "silver_entities", "silver_relations", "silver_decisions", "silver_rationales", "rejected", "gold_nodes", "gold_edges"]:
|
|
678
|
+
typer.echo(f"{key}: {state.counts.get(key, 0)}")
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
@app.command("inspect")
|
|
682
|
+
def inspect_corpus(corpus_id: str) -> None:
|
|
683
|
+
root = require_corpus(corpus_id)
|
|
684
|
+
state = CorpusStateStore(root).read()
|
|
685
|
+
typer.echo(f"Corpus: {corpus_id}")
|
|
686
|
+
typer.echo(f"Source path: {state.last_source_path or 'n/a'}")
|
|
687
|
+
for document in state.documents:
|
|
688
|
+
typer.echo(f"- {document.id} {document.source_id} {document.content_hash}")
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
@export_app.command("obsidian")
|
|
692
|
+
def export_obsidian(corpus_id: str, output_dir: Path) -> None:
|
|
693
|
+
root = require_corpus(corpus_id)
|
|
694
|
+
count = ObsidianExporter(GoldStore(root)).export(output_dir)
|
|
695
|
+
typer.echo(f"Exported {count} note(s): {output_dir}")
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
@lightrag_app.command("status")
|
|
699
|
+
def lightrag_status(corpus_id: str) -> None:
|
|
700
|
+
root = require_corpus(corpus_id)
|
|
701
|
+
status = LightRagAdapter(root).status()
|
|
702
|
+
typer.echo(f"Backend: {status.get('backend')}")
|
|
703
|
+
typer.echo(f"Package available: {status.get('package_available')}")
|
|
704
|
+
typer.echo(f"Ollama host: {status.get('ollama_host')}")
|
|
705
|
+
typer.echo(f"Ollama server available: {status.get('ollama_server_available')}")
|
|
706
|
+
if status.get("ollama_server_error"):
|
|
707
|
+
typer.echo(f"Ollama server error: {status.get('ollama_server_error')}")
|
|
708
|
+
typer.echo(f"Initialized: {status.get('initialized')}")
|
|
709
|
+
if status.get("error"):
|
|
710
|
+
typer.echo(f"Error: {status.get('error')}")
|
|
711
|
+
if status.get("backend_error"):
|
|
712
|
+
typer.echo(f"Backend error: {status.get('backend_error')}")
|
|
713
|
+
typer.echo(f"custom_kg: {status.get('custom_kg_path', 'n/a')}")
|
|
714
|
+
typer.echo(f"chunks: {status.get('chunks', 0)}")
|
|
715
|
+
typer.echo(f"entities: {status.get('entities', 0)}")
|
|
716
|
+
typer.echo(f"relationships: {status.get('relationships', 0)}")
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
@mcp_app.command("manifest")
|
|
720
|
+
def mcp_manifest(corpus_id: str) -> None:
|
|
721
|
+
root = require_corpus(corpus_id)
|
|
722
|
+
typer.echo(json.dumps(LocalMcpToolbox.manifest(root.name), ensure_ascii=False, indent=2))
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
@mcp_app.command("bootstrap-manifest")
|
|
726
|
+
def mcp_bootstrap_manifest() -> None:
|
|
727
|
+
typer.echo(json.dumps(BootstrapMcpToolbox.manifest(), ensure_ascii=False, indent=2))
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
@mcp_app.command("call")
|
|
731
|
+
def mcp_call(corpus_id: str, tool_name: str, arguments_json: str = typer.Argument("{}")) -> None:
|
|
732
|
+
root = require_corpus(corpus_id)
|
|
733
|
+
try:
|
|
734
|
+
arguments = json.loads(arguments_json)
|
|
735
|
+
result = LocalMcpToolbox(root).call(tool_name, arguments)
|
|
736
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
737
|
+
typer.echo(str(exc), err=True)
|
|
738
|
+
raise typer.Exit(1) from exc
|
|
739
|
+
typer.echo(json.dumps(result, ensure_ascii=False, indent=2))
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
@mcp_app.command("stdio")
|
|
743
|
+
def mcp_stdio(corpus_id: str | None = typer.Argument(None)) -> None:
|
|
744
|
+
root = require_corpus(corpus_id) if corpus_id is not None else None
|
|
745
|
+
McpStdioServer(root).run()
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _slack_config(
|
|
749
|
+
*,
|
|
750
|
+
client_id: str | None = None,
|
|
751
|
+
client_secret: str | None = None,
|
|
752
|
+
redirect_uri: str | None = None,
|
|
753
|
+
scopes: str | None = None,
|
|
754
|
+
) -> SlackOAuthConfig:
|
|
755
|
+
stored = load_config().slack
|
|
756
|
+
return SlackOAuthConfig(
|
|
757
|
+
client_id=client_id if client_id is not None else stored.client_id,
|
|
758
|
+
client_secret=client_secret if client_secret is not None else stored.client_secret,
|
|
759
|
+
redirect_uri=redirect_uri if redirect_uri is not None else stored.redirect_uri,
|
|
760
|
+
scopes=_parse_scopes(scopes) if scopes is not None else list(stored.scopes),
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _google_config(
|
|
765
|
+
*,
|
|
766
|
+
client_id: str | None = None,
|
|
767
|
+
client_secret: str | None = None,
|
|
768
|
+
redirect_uri: str | None = None,
|
|
769
|
+
scopes: str | None = None,
|
|
770
|
+
) -> GoogleOAuthConfig:
|
|
771
|
+
stored = load_config().google
|
|
772
|
+
return GoogleOAuthConfig(
|
|
773
|
+
client_id=client_id if client_id is not None else stored.client_id,
|
|
774
|
+
client_secret=client_secret if client_secret is not None else stored.client_secret,
|
|
775
|
+
redirect_uri=redirect_uri if redirect_uri is not None else stored.redirect_uri,
|
|
776
|
+
scopes=_parse_scopes(scopes) if scopes is not None else list(stored.scopes),
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _connector_login(
|
|
781
|
+
provider: str,
|
|
782
|
+
*,
|
|
783
|
+
account_id: str,
|
|
784
|
+
client_id: str | None,
|
|
785
|
+
client_secret: str | None,
|
|
786
|
+
redirect_uri: str | None,
|
|
787
|
+
scopes: str | None,
|
|
788
|
+
open_browser: bool,
|
|
789
|
+
timeout: int,
|
|
790
|
+
) -> dict:
|
|
791
|
+
normalized = provider.casefold().replace("_", "-")
|
|
792
|
+
if normalized == "google":
|
|
793
|
+
config = _google_config(client_id=client_id, client_secret=client_secret, redirect_uri=redirect_uri, scopes=scopes)
|
|
794
|
+
_require_option(config.client_id, "--client-id or [google].client_id", provider="Google OAuth")
|
|
795
|
+
_require_option(config.client_secret, "--client-secret or [google].client_secret", provider="Google OAuth")
|
|
796
|
+
state = new_oauth_state()
|
|
797
|
+
url = GoogleOAuthClient(config).authorization_url(state)
|
|
798
|
+
code = _wait_for_oauth_code(url, config.redirect_uri, state, open_browser=open_browser, timeout=timeout)
|
|
799
|
+
token = exchange_and_save_google_token(code, config, account_id=account_id, token_backend=_token_backend())
|
|
800
|
+
return token.model_dump(mode="json")
|
|
801
|
+
if normalized == "slack":
|
|
802
|
+
config = _slack_config(client_id=client_id, client_secret=client_secret, redirect_uri=redirect_uri, scopes=scopes)
|
|
803
|
+
_require_option(config.client_id, "--client-id or [slack].client_id")
|
|
804
|
+
_require_option(config.client_secret, "--client-secret or [slack].client_secret")
|
|
805
|
+
state = new_oauth_state()
|
|
806
|
+
url = SlackOAuthClient(config).authorization_url(state)
|
|
807
|
+
code = _wait_for_oauth_code(url, config.redirect_uri, state, open_browser=open_browser, timeout=timeout)
|
|
808
|
+
token = exchange_and_save_slack_token(code, config, token_backend=_token_backend())
|
|
809
|
+
return token.model_dump(mode="json")
|
|
810
|
+
|
|
811
|
+
spec = provider_spec(provider)
|
|
812
|
+
config = default_config(
|
|
813
|
+
spec.name,
|
|
814
|
+
client_id=client_id or "",
|
|
815
|
+
client_secret=client_secret or "",
|
|
816
|
+
redirect_uri=redirect_uri,
|
|
817
|
+
scopes=_parse_scopes(scopes) if scopes is not None else None,
|
|
818
|
+
)
|
|
819
|
+
_require_option(config.client_id, f"--client-id for {spec.name}", provider=f"{spec.name} OAuth")
|
|
820
|
+
_require_option(config.client_secret, f"--client-secret for {spec.name}", provider=f"{spec.name} OAuth")
|
|
821
|
+
state = new_oauth_state()
|
|
822
|
+
url = ConnectedAppOAuthClient(config, spec=spec).authorization_url(state)
|
|
823
|
+
code = _wait_for_oauth_code(url, config.redirect_uri, state, open_browser=open_browser, timeout=timeout)
|
|
824
|
+
token = exchange_and_save_connected_app_token(spec.name, code, config, account_id=account_id, token_backend=_token_backend())
|
|
825
|
+
return token.model_dump(mode="json")
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _wait_for_oauth_code(authorization_url: str, redirect_uri: str, state: str, *, open_browser: bool, timeout: int) -> str:
|
|
829
|
+
if not open_browser:
|
|
830
|
+
typer.echo(f"Open this URL to connect: {authorization_url}")
|
|
831
|
+
else:
|
|
832
|
+
typer.echo("Opening browser for OAuth login...")
|
|
833
|
+
result = run_local_oauth_login(
|
|
834
|
+
authorization_url,
|
|
835
|
+
redirect_uri,
|
|
836
|
+
state,
|
|
837
|
+
open_browser=open_browser,
|
|
838
|
+
timeout_seconds=timeout,
|
|
839
|
+
)
|
|
840
|
+
return result.code
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _load_registry() -> ModelRegistry:
|
|
844
|
+
return load_default_registry()
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _ame_command() -> str:
|
|
848
|
+
current = Path(sys.argv[0]).expanduser()
|
|
849
|
+
if current.name == "ame":
|
|
850
|
+
try:
|
|
851
|
+
return str(current.resolve())
|
|
852
|
+
except OSError:
|
|
853
|
+
return str(current)
|
|
854
|
+
found = shutil.which("ame")
|
|
855
|
+
if found:
|
|
856
|
+
return str(Path(found).expanduser().resolve())
|
|
857
|
+
return "ame"
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _ame_path_env() -> str | None:
|
|
861
|
+
bin_dir = _ame_bin_dir()
|
|
862
|
+
current_path = os.environ.get("PATH", "")
|
|
863
|
+
if bin_dir is None:
|
|
864
|
+
return current_path or None
|
|
865
|
+
bin_value = str(bin_dir)
|
|
866
|
+
parts = [part for part in current_path.split(os.pathsep) if part and part != bin_value]
|
|
867
|
+
return os.pathsep.join([bin_value, *parts])
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _ame_bin_dir() -> Path | None:
|
|
871
|
+
current = Path(sys.argv[0]).expanduser()
|
|
872
|
+
if current.name == "ame":
|
|
873
|
+
try:
|
|
874
|
+
return current.resolve().parent
|
|
875
|
+
except OSError:
|
|
876
|
+
return current.parent
|
|
877
|
+
found = shutil.which("ame")
|
|
878
|
+
if found:
|
|
879
|
+
return Path(found).expanduser().resolve().parent
|
|
880
|
+
return None
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _profile_disk_path() -> Path:
|
|
884
|
+
home = ame_home()
|
|
885
|
+
return home if home.exists() else Path.home()
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def _parse_scopes(value: str) -> list[str]:
|
|
889
|
+
return [item.strip() for item in value.split(",") if item.strip()]
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _token_backend() -> str:
|
|
893
|
+
return load_config().security.token_backend
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def _require_option(value: str, label: str, provider: str = "Slack OAuth") -> None:
|
|
897
|
+
if not value:
|
|
898
|
+
typer.echo(f"Missing required {provider} setting: {label}", err=True)
|
|
899
|
+
raise typer.Exit(1)
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
if __name__ == "__main__":
|
|
903
|
+
app()
|