amfs-cli 0.1.0__tar.gz
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.
- amfs_cli-0.1.0/.gitignore +16 -0
- amfs_cli-0.1.0/PKG-INFO +10 -0
- amfs_cli-0.1.0/pyproject.toml +22 -0
- amfs_cli-0.1.0/src/amfs_cli/__init__.py +1 -0
- amfs_cli-0.1.0/src/amfs_cli/init.py +77 -0
- amfs_cli-0.1.0/src/amfs_cli/inspect.py +139 -0
- amfs_cli-0.1.0/src/amfs_cli/login.py +43 -0
- amfs_cli-0.1.0/src/amfs_cli/main.py +55 -0
- amfs_cli-0.1.0/src/amfs_cli/recall.py +74 -0
- amfs_cli-0.1.0/src/amfs_cli/remote.py +71 -0
- amfs_cli-0.1.0/src/amfs_cli/search.py +103 -0
- amfs_cli-0.1.0/src/amfs_cli/snapshot.py +62 -0
- amfs_cli-0.1.0/src/amfs_cli/stats.py +58 -0
- amfs_cli-0.1.0/src/amfs_cli/status.py +68 -0
- amfs_cli-0.1.0/src/amfs_cli/watch.py +74 -0
- amfs_cli-0.1.0/src/amfs_cli/write.py +54 -0
amfs_cli-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: amfs-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AMFS CLI — snapshot, inspect, and manage agent memory
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: amfs
|
|
8
|
+
Requires-Dist: httpx>=0.27
|
|
9
|
+
Requires-Dist: rich<14,>=13
|
|
10
|
+
Requires-Dist: typer<1,>=0.12
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "amfs-cli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "AMFS CLI — snapshot, inspect, and manage agent memory"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"amfs",
|
|
9
|
+
"typer>=0.12,<1",
|
|
10
|
+
"rich>=13,<14",
|
|
11
|
+
"httpx>=0.27",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
amfs = "amfs_cli.main:app"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["hatchling"]
|
|
19
|
+
build-backend = "hatchling.build"
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["src/amfs_cli"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""AMFS CLI — snapshot, inspect, and manage agent memory."""
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""amfs init — scaffold an AMFS project in one command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
_DEFAULT_CONFIG = """\
|
|
13
|
+
# AMFS Configuration
|
|
14
|
+
# Docs: https://github.com/raia-live/amfs
|
|
15
|
+
|
|
16
|
+
namespace: {namespace}
|
|
17
|
+
|
|
18
|
+
layers:
|
|
19
|
+
primary:
|
|
20
|
+
adapter: filesystem
|
|
21
|
+
options:
|
|
22
|
+
root: .amfs
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
_GITIGNORE = """\
|
|
26
|
+
# AMFS local memory store
|
|
27
|
+
.amfs/
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def init_command(
|
|
32
|
+
directory: Path = typer.Argument(
|
|
33
|
+
Path("."), help="Directory to initialise (default: current dir)"
|
|
34
|
+
),
|
|
35
|
+
namespace: str = typer.Option(
|
|
36
|
+
"default", "--namespace", "-n", help="Namespace for this project"
|
|
37
|
+
),
|
|
38
|
+
force: bool = typer.Option(
|
|
39
|
+
False, "--force", "-f", help="Overwrite existing config"
|
|
40
|
+
),
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Initialise an AMFS project — creates config file and data directory."""
|
|
43
|
+
directory = directory.resolve()
|
|
44
|
+
config_path = directory / "amfs.yaml"
|
|
45
|
+
amfs_dir = directory / ".amfs"
|
|
46
|
+
|
|
47
|
+
if config_path.exists() and not force:
|
|
48
|
+
console.print(
|
|
49
|
+
f"[yellow]amfs.yaml already exists at {config_path}. "
|
|
50
|
+
f"Use --force to overwrite.[/yellow]"
|
|
51
|
+
)
|
|
52
|
+
raise typer.Exit(code=1)
|
|
53
|
+
|
|
54
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
config_path.write_text(_DEFAULT_CONFIG.format(namespace=namespace), encoding="utf-8")
|
|
56
|
+
amfs_dir.mkdir(exist_ok=True)
|
|
57
|
+
|
|
58
|
+
gitignore = directory / ".gitignore"
|
|
59
|
+
if gitignore.exists():
|
|
60
|
+
content = gitignore.read_text(encoding="utf-8")
|
|
61
|
+
if ".amfs/" not in content:
|
|
62
|
+
with open(gitignore, "a", encoding="utf-8") as f:
|
|
63
|
+
f.write("\n" + _GITIGNORE)
|
|
64
|
+
console.print("[dim]Updated .gitignore with .amfs/[/dim]")
|
|
65
|
+
else:
|
|
66
|
+
gitignore.write_text(_GITIGNORE, encoding="utf-8")
|
|
67
|
+
console.print("[dim]Created .gitignore[/dim]")
|
|
68
|
+
|
|
69
|
+
console.print("[green bold]AMFS initialised![/green bold]")
|
|
70
|
+
console.print(f" Config: {config_path}")
|
|
71
|
+
console.print(f" Data dir: {amfs_dir}")
|
|
72
|
+
console.print(f" Namespace: {namespace}")
|
|
73
|
+
console.print()
|
|
74
|
+
console.print("[dim]Quick start:[/dim]")
|
|
75
|
+
console.print(' from amfs import AgentMemory')
|
|
76
|
+
console.print(' mem = AgentMemory(agent_id="my-agent")')
|
|
77
|
+
console.print(' mem.write("my-service", "key", {"hello": "world"})')
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Inspect CLI commands — list, read, and diff memory entries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from amfs.config import load_config_or_default
|
|
13
|
+
from amfs.factory import create_adapter_from_config
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(no_args_is_help=True)
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command("list")
|
|
20
|
+
def list_entries(
|
|
21
|
+
entity: str | None = typer.Argument(None, help="Filter to entity path"),
|
|
22
|
+
config: Path | None = typer.Option(None, "--config", "-c", help="AMFS config file"),
|
|
23
|
+
superseded: bool = typer.Option(False, "--superseded", help="Include superseded versions"),
|
|
24
|
+
format: str = typer.Option("table", "--format", "-f", help="Output format: table, json"),
|
|
25
|
+
) -> None:
|
|
26
|
+
"""List memory entries."""
|
|
27
|
+
cfg = load_config_or_default(config)
|
|
28
|
+
adapter = create_adapter_from_config(cfg)
|
|
29
|
+
|
|
30
|
+
with console.status("[cyan]Loading entries...[/cyan]"):
|
|
31
|
+
entries = adapter.list(entity, include_superseded=superseded)
|
|
32
|
+
|
|
33
|
+
if not entries:
|
|
34
|
+
console.print("[dim]No entries found.[/dim]")
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
if format == "json":
|
|
38
|
+
console.print_json(json.dumps(
|
|
39
|
+
[{"entity_path": e.entity_path, "key": e.key, "version": e.version,
|
|
40
|
+
"confidence": e.confidence, "agent_id": e.provenance.agent_id,
|
|
41
|
+
"written_at": e.provenance.written_at.isoformat()}
|
|
42
|
+
for e in entries],
|
|
43
|
+
default=str,
|
|
44
|
+
))
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
table = Table(title=f"Memory Entries ({len(entries)})")
|
|
48
|
+
table.add_column("Entity", style="cyan")
|
|
49
|
+
table.add_column("Key", style="green")
|
|
50
|
+
table.add_column("Version", justify="right")
|
|
51
|
+
table.add_column("Confidence", justify="right")
|
|
52
|
+
table.add_column("Agent", style="magenta")
|
|
53
|
+
table.add_column("Written At")
|
|
54
|
+
|
|
55
|
+
for e in entries:
|
|
56
|
+
table.add_row(
|
|
57
|
+
e.entity_path,
|
|
58
|
+
e.key,
|
|
59
|
+
str(e.version),
|
|
60
|
+
f"{e.confidence:.4f}",
|
|
61
|
+
e.provenance.agent_id,
|
|
62
|
+
e.provenance.written_at.strftime("%Y-%m-%d %H:%M:%S"),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
console.print(table)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.command()
|
|
69
|
+
def read(
|
|
70
|
+
entity: str = typer.Argument(..., help="Entity path"),
|
|
71
|
+
key: str = typer.Argument(..., help="Key name"),
|
|
72
|
+
config: Path | None = typer.Option(None, "--config", "-c", help="AMFS config file"),
|
|
73
|
+
format: str = typer.Option("panel", "--format", "-f", help="Output format: panel, json"),
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Read a specific memory entry and print its value."""
|
|
76
|
+
cfg = load_config_or_default(config)
|
|
77
|
+
adapter = create_adapter_from_config(cfg)
|
|
78
|
+
entry = adapter.read(entity, key)
|
|
79
|
+
|
|
80
|
+
if entry is None:
|
|
81
|
+
console.print(f"[red]Entry not found: {entity}/{key}[/red]")
|
|
82
|
+
raise typer.Exit(code=1)
|
|
83
|
+
|
|
84
|
+
if format == "json":
|
|
85
|
+
console.print_json(json.dumps({
|
|
86
|
+
"entity_path": entry.entity_path, "key": entry.key,
|
|
87
|
+
"value": entry.value, "version": entry.version,
|
|
88
|
+
"confidence": entry.confidence,
|
|
89
|
+
"agent_id": entry.provenance.agent_id,
|
|
90
|
+
"written_at": entry.provenance.written_at.isoformat(),
|
|
91
|
+
}, default=str))
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
console.print(f"[bold]{entity}/{key}[/bold] v{entry.version}")
|
|
95
|
+
console.print(f"Confidence: {entry.confidence:.4f}")
|
|
96
|
+
console.print(f"Agent: {entry.provenance.agent_id} ({entry.provenance.session_id})")
|
|
97
|
+
console.print(f"Written: {entry.provenance.written_at}")
|
|
98
|
+
if entry.ttl_at:
|
|
99
|
+
console.print(f"TTL: {entry.ttl_at}")
|
|
100
|
+
console.print()
|
|
101
|
+
console.print_json(json.dumps(entry.value, default=str))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.command()
|
|
105
|
+
def diff(
|
|
106
|
+
entity: str = typer.Argument(..., help="Entity path"),
|
|
107
|
+
key: str = typer.Argument(..., help="Key name"),
|
|
108
|
+
config: Path | None = typer.Option(None, "--config", "-c", help="AMFS config file"),
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Show version history diff for a key."""
|
|
111
|
+
cfg = load_config_or_default(config)
|
|
112
|
+
adapter = create_adapter_from_config(cfg)
|
|
113
|
+
|
|
114
|
+
with console.status("[cyan]Loading history...[/cyan]"):
|
|
115
|
+
entries = adapter.list(entity, include_superseded=True)
|
|
116
|
+
|
|
117
|
+
key_entries = sorted(
|
|
118
|
+
[e for e in entries if e.key == key],
|
|
119
|
+
key=lambda e: e.version,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if not key_entries:
|
|
123
|
+
console.print(f"[red]No entries found for {entity}/{key}[/red]")
|
|
124
|
+
raise typer.Exit(code=1)
|
|
125
|
+
|
|
126
|
+
for i, entry in enumerate(key_entries):
|
|
127
|
+
console.print(f"[bold]v{entry.version}[/bold] (confidence: {entry.confidence:.4f})")
|
|
128
|
+
console.print(f" Agent: {entry.provenance.agent_id}")
|
|
129
|
+
console.print(f" Written: {entry.provenance.written_at}")
|
|
130
|
+
|
|
131
|
+
if i > 0:
|
|
132
|
+
prev_val = json.dumps(key_entries[i - 1].value, sort_keys=True, default=str)
|
|
133
|
+
curr_val = json.dumps(entry.value, sort_keys=True, default=str)
|
|
134
|
+
if prev_val != curr_val:
|
|
135
|
+
console.print(f" [red]- {prev_val}[/red]")
|
|
136
|
+
console.print(f" [green]+ {curr_val}[/green]")
|
|
137
|
+
else:
|
|
138
|
+
console.print(" [dim](value unchanged)[/dim]")
|
|
139
|
+
console.print()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""amfs login — store credentials for remote AMFS access."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
|
|
9
|
+
from amfs_cli.remote import save_credentials, get_client
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def login_command(
|
|
15
|
+
url: str = typer.Option(..., "--url", "-u", prompt="AMFS server URL", help="AMFS HTTP API URL"),
|
|
16
|
+
api_key: str = typer.Option(..., "--key", "-k", prompt="API key", hide_input=True, help="AMFS API key"),
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Store AMFS credentials for remote CLI access."""
|
|
19
|
+
with console.status("[cyan]Verifying connection...[/cyan]"):
|
|
20
|
+
try:
|
|
21
|
+
client = get_client.__wrapped__ if hasattr(get_client, "__wrapped__") else None # noqa: F841
|
|
22
|
+
import httpx
|
|
23
|
+
with httpx.Client(
|
|
24
|
+
base_url=url.rstrip("/"),
|
|
25
|
+
headers={"X-AMFS-API-Key": api_key},
|
|
26
|
+
timeout=10.0,
|
|
27
|
+
) as client:
|
|
28
|
+
resp = client.get("/api/v1/health")
|
|
29
|
+
resp.raise_for_status()
|
|
30
|
+
except Exception as exc:
|
|
31
|
+
console.print(f"[red]Connection failed:[/red] {exc}")
|
|
32
|
+
raise typer.Exit(code=1)
|
|
33
|
+
|
|
34
|
+
path = save_credentials(url, api_key)
|
|
35
|
+
console.print(
|
|
36
|
+
Panel(
|
|
37
|
+
f"[green]Credentials saved to[/green] {path}\n\n"
|
|
38
|
+
f" Server: [cyan]{url}[/cyan]\n"
|
|
39
|
+
f" Key: [dim]{api_key[:12]}{'•' * 20}[/dim]",
|
|
40
|
+
title="[bold green]Login successful[/bold green]",
|
|
41
|
+
border_style="green",
|
|
42
|
+
)
|
|
43
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""AMFS CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from amfs_cli.init import init_command
|
|
8
|
+
from amfs_cli.login import login_command
|
|
9
|
+
from amfs_cli.recall import recall_command
|
|
10
|
+
from amfs_cli.search import search_command
|
|
11
|
+
from amfs_cli.snapshot import app as snapshot_app
|
|
12
|
+
from amfs_cli.inspect import app as inspect_app
|
|
13
|
+
from amfs_cli.stats import stats_command
|
|
14
|
+
from amfs_cli.status import status_command
|
|
15
|
+
from amfs_cli.watch import watch_command
|
|
16
|
+
from amfs_cli.write import write_command
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
name="amfs",
|
|
20
|
+
help="AMFS — Agent Memory File System CLI",
|
|
21
|
+
no_args_is_help=True,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
app.command(name="init", help="Initialise an AMFS project")(init_command)
|
|
25
|
+
app.command(name="login", help="Store credentials for remote access")(login_command)
|
|
26
|
+
app.command(name="write", help="Write a memory entry")(write_command)
|
|
27
|
+
app.command(name="search", help="Search memory entries")(search_command)
|
|
28
|
+
app.command(name="recall", help="Recall an agent-scoped entry")(recall_command)
|
|
29
|
+
app.command(name="stats", help="Show memory statistics")(stats_command)
|
|
30
|
+
app.command(name="status", help="Show config and connection health")(status_command)
|
|
31
|
+
app.command(name="watch", help="Live-stream memory changes (SSE)")(watch_command)
|
|
32
|
+
app.add_typer(snapshot_app, name="snapshot", help="Export and restore memory snapshots")
|
|
33
|
+
app.add_typer(inspect_app, name="inspect", help="List, read, and diff memory entries")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _version_callback(value: bool) -> None:
|
|
37
|
+
if value:
|
|
38
|
+
from importlib.metadata import version
|
|
39
|
+
|
|
40
|
+
typer.echo(f"amfs-cli {version('amfs-cli')}")
|
|
41
|
+
raise typer.Exit()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.callback()
|
|
45
|
+
def main(
|
|
46
|
+
version: bool = typer.Option(
|
|
47
|
+
False, "--version", "-V", callback=_version_callback,
|
|
48
|
+
is_eager=True, help="Show version and exit",
|
|
49
|
+
),
|
|
50
|
+
) -> None:
|
|
51
|
+
"""AMFS — Agent Memory File System CLI."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
app()
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""amfs recall — recall agent-scoped memory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def recall_command(
|
|
15
|
+
entity: str = typer.Argument(..., help="Entity path"),
|
|
16
|
+
key: str = typer.Argument(..., help="Memory key"),
|
|
17
|
+
agent_id: str = typer.Option("cli", "--agent", "-a", help="Agent ID to recall as"),
|
|
18
|
+
format: str = typer.Option("panel", "--format", "-f", help="Output format: panel, json"),
|
|
19
|
+
remote: bool = typer.Option(False, "--remote", "-r", help="Recall via HTTP API"),
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Recall an agent-scoped memory entry."""
|
|
22
|
+
if remote:
|
|
23
|
+
from amfs_cli.remote import api_get
|
|
24
|
+
|
|
25
|
+
with console.status("[cyan]Recalling...[/cyan]"):
|
|
26
|
+
result = api_get(
|
|
27
|
+
f"/api/v1/entries/{entity}/{key}",
|
|
28
|
+
params={"agent_id": agent_id},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if format == "json":
|
|
32
|
+
console.print_json(json.dumps(result, default=str))
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
if not result:
|
|
36
|
+
console.print(f"[dim]No entry found for {entity}/{key}[/dim]")
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
console.print(Panel(
|
|
40
|
+
f"[bold]{entity}/{key}[/bold] v{result.get('version', '?')}\n"
|
|
41
|
+
f"Confidence: [green]{result.get('confidence', 0):.4f}[/green]\n"
|
|
42
|
+
f"Agent: [magenta]{result.get('agent_id', '')}[/magenta]\n\n"
|
|
43
|
+
f"{json.dumps(result.get('value'), indent=2, default=str)}",
|
|
44
|
+
title="[bold cyan]Recall[/bold cyan]",
|
|
45
|
+
border_style="cyan",
|
|
46
|
+
))
|
|
47
|
+
else:
|
|
48
|
+
from amfs import AgentMemory
|
|
49
|
+
|
|
50
|
+
with console.status("[cyan]Recalling...[/cyan]"):
|
|
51
|
+
mem = AgentMemory(agent_id=agent_id)
|
|
52
|
+
entry = mem.recall(entity, key)
|
|
53
|
+
|
|
54
|
+
if entry is None:
|
|
55
|
+
console.print(f"[dim]No entry found for {entity}/{key}[/dim]")
|
|
56
|
+
raise typer.Exit(code=1)
|
|
57
|
+
|
|
58
|
+
if format == "json":
|
|
59
|
+
console.print_json(json.dumps({
|
|
60
|
+
"entity_path": entry.entity_path, "key": entry.key,
|
|
61
|
+
"value": entry.value, "version": entry.version,
|
|
62
|
+
"confidence": entry.confidence,
|
|
63
|
+
"agent_id": entry.provenance.agent_id,
|
|
64
|
+
}, default=str))
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
console.print(Panel(
|
|
68
|
+
f"[bold]{entity}/{key}[/bold] v{entry.version}\n"
|
|
69
|
+
f"Confidence: [green]{entry.confidence:.4f}[/green]\n"
|
|
70
|
+
f"Agent: [magenta]{entry.provenance.agent_id}[/magenta]\n\n"
|
|
71
|
+
f"{json.dumps(entry.value, indent=2, default=str)}",
|
|
72
|
+
title="[bold cyan]Recall[/bold cyan]",
|
|
73
|
+
border_style="cyan",
|
|
74
|
+
))
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Shared HTTP client helper for remote AMFS operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
console = Console(stderr=True)
|
|
15
|
+
|
|
16
|
+
_CRED_DIR = Path.home() / ".config" / "amfs"
|
|
17
|
+
_CRED_FILE = _CRED_DIR / "credentials.json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load_credentials() -> dict[str, str]:
|
|
21
|
+
if _CRED_FILE.exists():
|
|
22
|
+
return json.loads(_CRED_FILE.read_text(encoding="utf-8"))
|
|
23
|
+
return {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def save_credentials(url: str, api_key: str) -> Path:
|
|
27
|
+
_CRED_DIR.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
_CRED_FILE.write_text(
|
|
29
|
+
json.dumps({"url": url, "api_key": api_key}, indent=2),
|
|
30
|
+
encoding="utf-8",
|
|
31
|
+
)
|
|
32
|
+
_CRED_FILE.chmod(0o600)
|
|
33
|
+
return _CRED_FILE
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_client() -> httpx.Client:
|
|
37
|
+
"""Build an httpx client from env vars or stored credentials."""
|
|
38
|
+
url = os.environ.get("AMFS_HTTP_URL")
|
|
39
|
+
api_key = os.environ.get("AMFS_API_KEY")
|
|
40
|
+
|
|
41
|
+
if not url:
|
|
42
|
+
creds = _load_credentials()
|
|
43
|
+
url = creds.get("url")
|
|
44
|
+
api_key = api_key or creds.get("api_key")
|
|
45
|
+
|
|
46
|
+
if not url:
|
|
47
|
+
console.print(
|
|
48
|
+
"[red]No AMFS server configured.[/red]\n"
|
|
49
|
+
"Run [bold]amfs login[/bold] or set AMFS_HTTP_URL.",
|
|
50
|
+
)
|
|
51
|
+
sys.exit(1)
|
|
52
|
+
|
|
53
|
+
headers: dict[str, str] = {}
|
|
54
|
+
if api_key:
|
|
55
|
+
headers["X-AMFS-API-Key"] = api_key
|
|
56
|
+
|
|
57
|
+
return httpx.Client(base_url=url.rstrip("/"), headers=headers, timeout=30.0)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def api_get(path: str, params: dict[str, Any] | None = None) -> Any:
|
|
61
|
+
with get_client() as client:
|
|
62
|
+
resp = client.get(path, params=params)
|
|
63
|
+
resp.raise_for_status()
|
|
64
|
+
return resp.json()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def api_post(path: str, payload: dict[str, Any]) -> Any:
|
|
68
|
+
with get_client() as client:
|
|
69
|
+
resp = client.post(path, json=payload)
|
|
70
|
+
resp.raise_for_status()
|
|
71
|
+
return resp.json()
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""amfs search — search memory entries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(no_args_is_help=False)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def search_command(
|
|
17
|
+
query: str = typer.Argument(None, help="Search query text"),
|
|
18
|
+
entity: str | None = typer.Option(None, "--entity", "-e", help="Filter by entity path"),
|
|
19
|
+
agent_id: str | None = typer.Option(None, "--agent", "-a", help="Filter by agent ID"),
|
|
20
|
+
min_confidence: float = typer.Option(0.0, "--min-confidence", help="Minimum confidence"),
|
|
21
|
+
limit: int = typer.Option(50, "--limit", "-l", help="Max results"),
|
|
22
|
+
format: str = typer.Option("table", "--format", "-f", help="Output format: table, json"),
|
|
23
|
+
remote: bool = typer.Option(False, "--remote", "-r", help="Search via HTTP API"),
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Search memory entries by query, entity, agent, or confidence."""
|
|
26
|
+
if remote:
|
|
27
|
+
from amfs_cli.remote import api_post
|
|
28
|
+
|
|
29
|
+
with console.status("[cyan]Searching...[/cyan]"):
|
|
30
|
+
results = api_post("/api/v1/search", {
|
|
31
|
+
"query": query,
|
|
32
|
+
"entity_path": entity,
|
|
33
|
+
"agent_id": agent_id,
|
|
34
|
+
"min_confidence": min_confidence,
|
|
35
|
+
"limit": limit,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
if format == "json":
|
|
39
|
+
console.print_json(json.dumps(results, default=str))
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
if not results:
|
|
43
|
+
console.print("[dim]No results found.[/dim]")
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
table = Table(title=f"Search Results ({len(results)} entries)")
|
|
47
|
+
table.add_column("Entity", style="cyan")
|
|
48
|
+
table.add_column("Key", style="green")
|
|
49
|
+
table.add_column("Confidence", justify="right")
|
|
50
|
+
table.add_column("Agent", style="magenta")
|
|
51
|
+
table.add_column("Value", max_width=50)
|
|
52
|
+
|
|
53
|
+
for r in results:
|
|
54
|
+
table.add_row(
|
|
55
|
+
r.get("entity_path", ""),
|
|
56
|
+
r.get("key", ""),
|
|
57
|
+
f"{r.get('confidence', 0):.4f}",
|
|
58
|
+
r.get("agent_id", ""),
|
|
59
|
+
str(r.get("value", ""))[:50],
|
|
60
|
+
)
|
|
61
|
+
console.print(table)
|
|
62
|
+
else:
|
|
63
|
+
from amfs import AgentMemory
|
|
64
|
+
|
|
65
|
+
with console.status("[cyan]Searching...[/cyan]"):
|
|
66
|
+
mem = AgentMemory(agent_id="cli")
|
|
67
|
+
results = mem.search(
|
|
68
|
+
query=query,
|
|
69
|
+
entity_path=entity,
|
|
70
|
+
agent_id=agent_id,
|
|
71
|
+
min_confidence=min_confidence,
|
|
72
|
+
limit=limit,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if format == "json":
|
|
76
|
+
console.print_json(json.dumps(
|
|
77
|
+
[{"entity_path": e.entity_path, "key": e.key, "value": e.value,
|
|
78
|
+
"confidence": e.confidence, "agent_id": e.provenance.agent_id}
|
|
79
|
+
for e in results],
|
|
80
|
+
default=str,
|
|
81
|
+
))
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
if not results:
|
|
85
|
+
console.print("[dim]No results found.[/dim]")
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
table = Table(title=f"Search Results ({len(results)} entries)")
|
|
89
|
+
table.add_column("Entity", style="cyan")
|
|
90
|
+
table.add_column("Key", style="green")
|
|
91
|
+
table.add_column("Confidence", justify="right")
|
|
92
|
+
table.add_column("Agent", style="magenta")
|
|
93
|
+
table.add_column("Value", max_width=50)
|
|
94
|
+
|
|
95
|
+
for e in results:
|
|
96
|
+
table.add_row(
|
|
97
|
+
e.entity_path,
|
|
98
|
+
e.key,
|
|
99
|
+
f"{e.confidence:.4f}",
|
|
100
|
+
e.provenance.agent_id,
|
|
101
|
+
str(e.value)[:50],
|
|
102
|
+
)
|
|
103
|
+
console.print(table)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Snapshot CLI commands — export and restore."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
|
|
10
|
+
|
|
11
|
+
from amfs.config import load_config_or_default
|
|
12
|
+
from amfs.factory import create_adapter_from_config
|
|
13
|
+
from amfs_core.snapshot import SnapshotExporter, SnapshotImporter
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(no_args_is_help=True)
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command()
|
|
20
|
+
def export(
|
|
21
|
+
output: Path = typer.Argument(..., help="Output snapshot file path"),
|
|
22
|
+
config: Path | None = typer.Option(None, "--config", "-c", help="AMFS config file"),
|
|
23
|
+
entity: str | None = typer.Option(None, "--entity", "-e", help="Filter to entity path"),
|
|
24
|
+
include_superseded: bool = typer.Option(False, "--superseded", help="Include superseded versions"),
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Export current memory state to a JSON snapshot."""
|
|
27
|
+
cfg = load_config_or_default(config)
|
|
28
|
+
adapter = create_adapter_from_config(cfg)
|
|
29
|
+
with Progress(
|
|
30
|
+
SpinnerColumn(),
|
|
31
|
+
TextColumn("[progress.description]{task.description}"),
|
|
32
|
+
BarColumn(),
|
|
33
|
+
TextColumn("{task.completed} entries"),
|
|
34
|
+
console=console,
|
|
35
|
+
) as progress:
|
|
36
|
+
task = progress.add_task("Exporting...", total=None)
|
|
37
|
+
exporter = SnapshotExporter(adapter)
|
|
38
|
+
count = exporter.export(output, entity_path=entity, include_superseded=include_superseded)
|
|
39
|
+
progress.update(task, completed=count, total=count)
|
|
40
|
+
console.print(f"[green]Exported {count} entries to {output}[/green]")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command()
|
|
44
|
+
def restore(
|
|
45
|
+
input_path: Path = typer.Argument(..., help="Snapshot file to restore"),
|
|
46
|
+
config: Path | None = typer.Option(None, "--config", "-c", help="AMFS config file"),
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Restore memory entries from a snapshot file."""
|
|
49
|
+
cfg = load_config_or_default(config)
|
|
50
|
+
adapter = create_adapter_from_config(cfg)
|
|
51
|
+
with Progress(
|
|
52
|
+
SpinnerColumn(),
|
|
53
|
+
TextColumn("[progress.description]{task.description}"),
|
|
54
|
+
BarColumn(),
|
|
55
|
+
TextColumn("{task.completed} entries"),
|
|
56
|
+
console=console,
|
|
57
|
+
) as progress:
|
|
58
|
+
task = progress.add_task("Restoring...", total=None)
|
|
59
|
+
importer = SnapshotImporter(adapter)
|
|
60
|
+
count = importer.restore(input_path)
|
|
61
|
+
progress.update(task, completed=count, total=count)
|
|
62
|
+
console.print(f"[green]Restored {count} entries from {input_path}[/green]")
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""amfs stats — display memory statistics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def stats_command(
|
|
16
|
+
format: str = typer.Option("panel", "--format", "-f", help="Output format: panel, json"),
|
|
17
|
+
remote: bool = typer.Option(False, "--remote", "-r", help="Fetch via HTTP API"),
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Show memory statistics (entries, agents, entities)."""
|
|
20
|
+
if remote:
|
|
21
|
+
from amfs_cli.remote import api_get
|
|
22
|
+
|
|
23
|
+
with console.status("[cyan]Fetching stats...[/cyan]"):
|
|
24
|
+
data = api_get("/api/v1/stats")
|
|
25
|
+
else:
|
|
26
|
+
from amfs import AgentMemory
|
|
27
|
+
|
|
28
|
+
with console.status("[cyan]Computing stats...[/cyan]"):
|
|
29
|
+
mem = AgentMemory(agent_id="cli")
|
|
30
|
+
s = mem.stats()
|
|
31
|
+
data = {
|
|
32
|
+
"total_entries": s.total_entries,
|
|
33
|
+
"total_entities": s.total_entities,
|
|
34
|
+
"total_agents": s.total_agents,
|
|
35
|
+
"avg_confidence": round(s.avg_confidence, 4) if s.avg_confidence else 0,
|
|
36
|
+
"memory_types": s.memory_types if hasattr(s, "memory_types") else {},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if format == "json":
|
|
40
|
+
console.print_json(json.dumps(data, default=str))
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
44
|
+
table.add_column("Metric", style="bold cyan")
|
|
45
|
+
table.add_column("Value", justify="right", style="green")
|
|
46
|
+
|
|
47
|
+
table.add_row("Total Entries", str(data.get("total_entries", 0)))
|
|
48
|
+
table.add_row("Total Entities", str(data.get("total_entities", 0)))
|
|
49
|
+
table.add_row("Total Agents", str(data.get("total_agents", 0)))
|
|
50
|
+
table.add_row("Avg Confidence", f"{data.get('avg_confidence', 0):.4f}")
|
|
51
|
+
|
|
52
|
+
types = data.get("memory_types", {})
|
|
53
|
+
if types:
|
|
54
|
+
table.add_row("", "")
|
|
55
|
+
for mt, count in types.items():
|
|
56
|
+
table.add_row(f" {mt}", str(count))
|
|
57
|
+
|
|
58
|
+
console.print(Panel(table, title="[bold]Memory Statistics[/bold]", border_style="cyan"))
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""amfs status — show configuration and connection health."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def status_command() -> None:
|
|
17
|
+
"""Show current AMFS configuration, adapter, and connection health."""
|
|
18
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
19
|
+
table.add_column("Setting", style="bold")
|
|
20
|
+
table.add_column("Value")
|
|
21
|
+
|
|
22
|
+
config_path = Path("amfs.yaml")
|
|
23
|
+
table.add_row("Config file", str(config_path) if config_path.exists() else "[dim]not found[/dim]")
|
|
24
|
+
|
|
25
|
+
http_url = os.environ.get("AMFS_HTTP_URL")
|
|
26
|
+
api_key = os.environ.get("AMFS_API_KEY")
|
|
27
|
+
pg_dsn = os.environ.get("AMFS_POSTGRES_DSN")
|
|
28
|
+
data_dir = os.environ.get("AMFS_DATA_DIR")
|
|
29
|
+
|
|
30
|
+
cred_file = Path.home() / ".config" / "amfs" / "credentials.json"
|
|
31
|
+
if cred_file.exists():
|
|
32
|
+
import json
|
|
33
|
+
creds = json.loads(cred_file.read_text(encoding="utf-8"))
|
|
34
|
+
if not http_url:
|
|
35
|
+
http_url = creds.get("url")
|
|
36
|
+
if not api_key:
|
|
37
|
+
api_key = creds.get("api_key")
|
|
38
|
+
|
|
39
|
+
if http_url:
|
|
40
|
+
table.add_row("Mode", "[cyan]Remote (HTTP API)[/cyan]")
|
|
41
|
+
table.add_row("Server URL", f"[green]{http_url}[/green]")
|
|
42
|
+
table.add_row("API Key", f"[dim]{api_key[:12]}{'•' * 20}[/dim]" if api_key else "[yellow]not set[/yellow]")
|
|
43
|
+
elif pg_dsn:
|
|
44
|
+
table.add_row("Mode", "[cyan]Postgres[/cyan]")
|
|
45
|
+
host = pg_dsn.split("@")[-1].split("/")[0] if "@" in pg_dsn else pg_dsn[:40]
|
|
46
|
+
table.add_row("DSN", f"[dim]{host}[/dim]")
|
|
47
|
+
elif data_dir:
|
|
48
|
+
table.add_row("Mode", "[cyan]Filesystem[/cyan]")
|
|
49
|
+
table.add_row("Data dir", data_dir)
|
|
50
|
+
elif config_path.exists():
|
|
51
|
+
table.add_row("Mode", "[cyan]Config file[/cyan]")
|
|
52
|
+
else:
|
|
53
|
+
table.add_row("Mode", "[yellow]Default (filesystem .amfs/)[/yellow]")
|
|
54
|
+
|
|
55
|
+
if http_url:
|
|
56
|
+
with console.status("[cyan]Checking connection...[/cyan]"):
|
|
57
|
+
try:
|
|
58
|
+
import httpx
|
|
59
|
+
headers = {"X-AMFS-API-Key": api_key} if api_key else {}
|
|
60
|
+
resp = httpx.get(f"{http_url.rstrip('/')}/api/v1/health", headers=headers, timeout=5.0)
|
|
61
|
+
if resp.status_code == 200:
|
|
62
|
+
table.add_row("Connection", "[green]Healthy[/green]")
|
|
63
|
+
else:
|
|
64
|
+
table.add_row("Connection", f"[yellow]HTTP {resp.status_code}[/yellow]")
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
table.add_row("Connection", f"[red]Failed: {exc}[/red]")
|
|
67
|
+
|
|
68
|
+
console.print(Panel(table, title="[bold]AMFS Status[/bold]", border_style="cyan"))
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""amfs watch — live-stream memory changes via SSE."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.live import Live
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def watch_command(
|
|
17
|
+
entity: str | None = typer.Argument(None, help="Filter to entity path (default: all)"),
|
|
18
|
+
limit: int = typer.Option(20, "--limit", "-l", help="Max rows to display"),
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Live-stream memory changes from a remote AMFS server (SSE)."""
|
|
21
|
+
from amfs_cli.remote import get_client
|
|
22
|
+
|
|
23
|
+
client = get_client()
|
|
24
|
+
params = {"entity_path": entity or "*"}
|
|
25
|
+
|
|
26
|
+
events: list[dict] = []
|
|
27
|
+
|
|
28
|
+
def _build_table() -> Table:
|
|
29
|
+
table = Table(title="Live Memory Stream", expand=True)
|
|
30
|
+
table.add_column("Entity", style="cyan", max_width=30)
|
|
31
|
+
table.add_column("Key", style="green", max_width=20)
|
|
32
|
+
table.add_column("Agent", style="magenta", max_width=15)
|
|
33
|
+
table.add_column("Version", justify="right")
|
|
34
|
+
table.add_column("Value", max_width=40)
|
|
35
|
+
for ev in events[-limit:]:
|
|
36
|
+
table.add_row(
|
|
37
|
+
ev.get("entity_path", ""),
|
|
38
|
+
ev.get("key", ""),
|
|
39
|
+
ev.get("agent_id", ""),
|
|
40
|
+
str(ev.get("version", "")),
|
|
41
|
+
str(ev.get("value", ""))[:40],
|
|
42
|
+
)
|
|
43
|
+
return table
|
|
44
|
+
|
|
45
|
+
console.print(f"[cyan]Connecting to SSE stream...[/cyan] (entity: {entity or '*'})")
|
|
46
|
+
console.print("[dim]Press Ctrl-C to stop[/dim]\n")
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
with Live(_build_table(), console=console, refresh_per_second=2) as live:
|
|
50
|
+
with client.stream(
|
|
51
|
+
"GET", "/api/v1/stream", params=params, timeout=None
|
|
52
|
+
) as resp:
|
|
53
|
+
resp.raise_for_status()
|
|
54
|
+
buf = ""
|
|
55
|
+
for chunk in resp.iter_text():
|
|
56
|
+
buf += chunk
|
|
57
|
+
while "\n\n" in buf:
|
|
58
|
+
msg, buf = buf.split("\n\n", 1)
|
|
59
|
+
for line in msg.split("\n"):
|
|
60
|
+
if line.startswith("data:"):
|
|
61
|
+
raw = line[5:].strip()
|
|
62
|
+
if raw:
|
|
63
|
+
try:
|
|
64
|
+
events.append(json.loads(raw))
|
|
65
|
+
live.update(_build_table())
|
|
66
|
+
except json.JSONDecodeError:
|
|
67
|
+
pass
|
|
68
|
+
except KeyboardInterrupt:
|
|
69
|
+
console.print(f"\n[dim]Stopped. Received {len(events)} events.[/dim]")
|
|
70
|
+
except httpx.HTTPStatusError as exc:
|
|
71
|
+
console.print(f"[red]Stream error:[/red] {exc.response.status_code}")
|
|
72
|
+
raise typer.Exit(code=1)
|
|
73
|
+
finally:
|
|
74
|
+
client.close()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""amfs write — write a memory entry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def write_command(
|
|
15
|
+
entity: str = typer.Argument(..., help="Entity path (e.g. myapp/auth)"),
|
|
16
|
+
key: str = typer.Argument(..., help="Memory key"),
|
|
17
|
+
value: str = typer.Argument(
|
|
18
|
+
None, help="JSON value (reads from stdin if omitted)"
|
|
19
|
+
),
|
|
20
|
+
agent_id: str = typer.Option("cli", "--agent", "-a", help="Agent ID"),
|
|
21
|
+
confidence: float = typer.Option(1.0, "--confidence", "-c", help="Confidence score"),
|
|
22
|
+
memory_type: str = typer.Option("fact", "--type", "-t", help="Memory type: fact, belief, experience"),
|
|
23
|
+
remote: bool = typer.Option(False, "--remote", "-r", help="Write via HTTP API instead of local adapter"),
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Write a memory entry to AMFS."""
|
|
26
|
+
if value is None:
|
|
27
|
+
if sys.stdin.isatty():
|
|
28
|
+
console.print("[dim]Enter JSON value (Ctrl-D to finish):[/dim]")
|
|
29
|
+
value = sys.stdin.read().strip()
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
parsed = json.loads(value)
|
|
33
|
+
except json.JSONDecodeError:
|
|
34
|
+
parsed = value
|
|
35
|
+
|
|
36
|
+
if remote:
|
|
37
|
+
from amfs_cli.remote import api_post
|
|
38
|
+
|
|
39
|
+
with console.status("[cyan]Writing entry...[/cyan]"):
|
|
40
|
+
result = api_post("/api/v1/entries", {
|
|
41
|
+
"entity_path": entity,
|
|
42
|
+
"key": key,
|
|
43
|
+
"value": parsed,
|
|
44
|
+
"confidence": confidence,
|
|
45
|
+
"memory_type": memory_type,
|
|
46
|
+
})
|
|
47
|
+
console.print(f"[green]Written[/green] {entity}/{key} v{result.get('version', '?')}")
|
|
48
|
+
else:
|
|
49
|
+
from amfs import AgentMemory
|
|
50
|
+
|
|
51
|
+
with console.status("[cyan]Writing entry...[/cyan]"):
|
|
52
|
+
mem = AgentMemory(agent_id=agent_id)
|
|
53
|
+
entry = mem.write(entity, key, parsed, confidence=confidence, memory_type=memory_type)
|
|
54
|
+
console.print(f"[green]Written[/green] {entity}/{key} v{entry.version}")
|