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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. adaptive_memory_engine-0.1.6.dist-info/METADATA +228 -0
  2. adaptive_memory_engine-0.1.6.dist-info/RECORD +72 -0
  3. adaptive_memory_engine-0.1.6.dist-info/WHEEL +4 -0
  4. adaptive_memory_engine-0.1.6.dist-info/entry_points.txt +3 -0
  5. adaptive_memory_engine-0.1.6.dist-info/licenses/LICENSE +21 -0
  6. ame/__init__.py +1 -0
  7. ame/agent/__init__.py +1 -0
  8. ame/agent/mcp.py +474 -0
  9. ame/agent/memory_api.py +141 -0
  10. ame/agent/results.py +30 -0
  11. ame/bronze/schema.py +17 -0
  12. ame/bronze/store.py +38 -0
  13. ame/cli/__init__.py +1 -0
  14. ame/cli/main.py +903 -0
  15. ame/connectors/base.py +30 -0
  16. ame/connectors/contract.py +199 -0
  17. ame/connectors/github.py +66 -0
  18. ame/connectors/google.py +464 -0
  19. ame/connectors/google_oauth.py +156 -0
  20. ame/connectors/jira.py +66 -0
  21. ame/connectors/json_helpers.py +43 -0
  22. ame/connectors/markdown.py +116 -0
  23. ame/connectors/notion.py +59 -0
  24. ame/connectors/oauth_callback.py +102 -0
  25. ame/connectors/oauth_provider.py +250 -0
  26. ame/connectors/obsidian.py +19 -0
  27. ame/connectors/router.py +155 -0
  28. ame/connectors/slack.py +66 -0
  29. ame/connectors/slack_oauth.py +417 -0
  30. ame/connectors/sync_history.py +73 -0
  31. ame/context_budget.py +106 -0
  32. ame/core/config.py +77 -0
  33. ame/core/corpus.py +17 -0
  34. ame/core/errors.py +18 -0
  35. ame/core/paths.py +111 -0
  36. ame/core/state.py +57 -0
  37. ame/export/obsidian.py +123 -0
  38. ame/gold/builder.py +300 -0
  39. ame/gold/ontology.py +80 -0
  40. ame/gold/resolver.py +91 -0
  41. ame/gold/schema.py +40 -0
  42. ame/gold/store.py +45 -0
  43. ame/hardware/profiler.py +85 -0
  44. ame/hardware/tier.py +27 -0
  45. ame/hermes/__init__.py +3 -0
  46. ame/hermes/memory.py +209 -0
  47. ame/models/download.py +243 -0
  48. ame/models/ollama.py +60 -0
  49. ame/models/registry.py +101 -0
  50. ame/models/router.py +22 -0
  51. ame/pipeline.py +155 -0
  52. ame/query/diff.py +40 -0
  53. ame/query/engine.py +919 -0
  54. ame/query/memory_os.py +313 -0
  55. ame/query/mql.py +84 -0
  56. ame/query/multihop.py +264 -0
  57. ame/query/result.py +20 -0
  58. ame/sdk.py +52 -0
  59. ame/security.py +145 -0
  60. ame/silver/extractor.py +414 -0
  61. ame/silver/llm_extractor.py +181 -0
  62. ame/silver/prompts.py +56 -0
  63. ame/silver/rationale.py +140 -0
  64. ame/silver/schema.py +51 -0
  65. ame/silver/store.py +59 -0
  66. ame/storage/custom_kg.py +33 -0
  67. ame/storage/lightrag_adapter.py +362 -0
  68. ame/validation/confidence.py +5 -0
  69. ame/validation/grounding.py +10 -0
  70. ame/validation/type_gate.py +22 -0
  71. ame/writeback.py +173 -0
  72. memory/__init__.py +3 -0
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()