exemplar-cli 0.1.2__tar.gz → 0.1.3__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.
- {exemplar_cli-0.1.2 → exemplar_cli-0.1.3}/PKG-INFO +30 -1
- {exemplar_cli-0.1.2 → exemplar_cli-0.1.3}/README.md +29 -0
- exemplar_cli-0.1.3/exemplar_cli/_scopes.py +49 -0
- exemplar_cli-0.1.3/exemplar_cli/_skill_targets.py +205 -0
- exemplar_cli-0.1.3/exemplar_cli/main.py +71 -0
- exemplar_cli-0.1.3/exemplar_cli/memory.py +264 -0
- exemplar_cli-0.1.3/exemplar_cli/prompts.py +234 -0
- exemplar_cli-0.1.3/exemplar_cli/skills.py +334 -0
- {exemplar_cli-0.1.2 → exemplar_cli-0.1.3}/pyproject.toml +1 -1
- exemplar_cli-0.1.2/exemplar_cli/_scopes.py +0 -23
- exemplar_cli-0.1.2/exemplar_cli/main.py +0 -35
- exemplar_cli-0.1.2/exemplar_cli/memory.py +0 -196
- exemplar_cli-0.1.2/exemplar_cli/prompts.py +0 -171
- exemplar_cli-0.1.2/exemplar_cli/skills.py +0 -205
- {exemplar_cli-0.1.2 → exemplar_cli-0.1.3}/exemplar_cli/__init__.py +0 -0
- {exemplar_cli-0.1.2 → exemplar_cli-0.1.3}/exemplar_cli/doctor.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: exemplar-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Exemplar terminal CLI for skills, prompts, and memory
|
|
5
5
|
License: LicenseRef-Proprietary
|
|
6
6
|
Author: Exemplar Dev LLC
|
|
@@ -71,6 +71,7 @@ exemplar memory search "formatting" --user-id u1
|
|
|
71
71
|
| `EXEMPLAR_API_KEY` | Org API key (required) |
|
|
72
72
|
| `EXEMPLAR_BASE_URL` | API host override (optional) |
|
|
73
73
|
| `EXEMPLAR_ORGANIZATION_ID` | Org override for JWT auth (optional) |
|
|
74
|
+
| `EXEMPLAR_SKILLS_TARGET` | Default skill install preset: `cursor`, `cursor-global`, `claude`, `claude-global` |
|
|
74
75
|
|
|
75
76
|
## Commands
|
|
76
77
|
|
|
@@ -82,6 +83,34 @@ exemplar memory search "formatting" --user-id u1
|
|
|
82
83
|
|
|
83
84
|
`list`, `get`, `search`, `pull`, `push`, `init`, `export-zip`
|
|
84
85
|
|
|
86
|
+
**Skill install destinations** (`pull`, `init`):
|
|
87
|
+
|
|
88
|
+
| Target | Path | Confirmation |
|
|
89
|
+
|--------|------|----------------|
|
|
90
|
+
| `cursor` (default) | `.cursor/skills` | No |
|
|
91
|
+
| `cursor-global` | `~/.cursor/skills` | No |
|
|
92
|
+
| `claude` | `.claude/skills` | Yes |
|
|
93
|
+
| `claude-global` | `~/.claude/skills` | Yes |
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# Default — Cursor project skills (no prompt)
|
|
97
|
+
exemplar skills pull refund-policy
|
|
98
|
+
|
|
99
|
+
# Global Cursor skills
|
|
100
|
+
exemplar skills pull refund-policy --global
|
|
101
|
+
|
|
102
|
+
# Claude Code — asks for confirmation (or use -y)
|
|
103
|
+
exemplar skills pull refund-policy --target claude-global
|
|
104
|
+
|
|
105
|
+
# Pick interactively when multiple agents are installed
|
|
106
|
+
exemplar skills pull --all -i
|
|
107
|
+
|
|
108
|
+
# Override with a custom directory
|
|
109
|
+
exemplar skills pull refund-policy --dest ./my-skills
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Set a default via `EXEMPLAR_SKILLS_TARGET=cursor-global`.
|
|
113
|
+
|
|
85
114
|
### Prompts
|
|
86
115
|
|
|
87
116
|
`list`, `get`, `search`, `create`, `run`, `publish`, `delete`
|
|
@@ -56,6 +56,7 @@ exemplar memory search "formatting" --user-id u1
|
|
|
56
56
|
| `EXEMPLAR_API_KEY` | Org API key (required) |
|
|
57
57
|
| `EXEMPLAR_BASE_URL` | API host override (optional) |
|
|
58
58
|
| `EXEMPLAR_ORGANIZATION_ID` | Org override for JWT auth (optional) |
|
|
59
|
+
| `EXEMPLAR_SKILLS_TARGET` | Default skill install preset: `cursor`, `cursor-global`, `claude`, `claude-global` |
|
|
59
60
|
|
|
60
61
|
## Commands
|
|
61
62
|
|
|
@@ -67,6 +68,34 @@ exemplar memory search "formatting" --user-id u1
|
|
|
67
68
|
|
|
68
69
|
`list`, `get`, `search`, `pull`, `push`, `init`, `export-zip`
|
|
69
70
|
|
|
71
|
+
**Skill install destinations** (`pull`, `init`):
|
|
72
|
+
|
|
73
|
+
| Target | Path | Confirmation |
|
|
74
|
+
|--------|------|----------------|
|
|
75
|
+
| `cursor` (default) | `.cursor/skills` | No |
|
|
76
|
+
| `cursor-global` | `~/.cursor/skills` | No |
|
|
77
|
+
| `claude` | `.claude/skills` | Yes |
|
|
78
|
+
| `claude-global` | `~/.claude/skills` | Yes |
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
# Default — Cursor project skills (no prompt)
|
|
82
|
+
exemplar skills pull refund-policy
|
|
83
|
+
|
|
84
|
+
# Global Cursor skills
|
|
85
|
+
exemplar skills pull refund-policy --global
|
|
86
|
+
|
|
87
|
+
# Claude Code — asks for confirmation (or use -y)
|
|
88
|
+
exemplar skills pull refund-policy --target claude-global
|
|
89
|
+
|
|
90
|
+
# Pick interactively when multiple agents are installed
|
|
91
|
+
exemplar skills pull --all -i
|
|
92
|
+
|
|
93
|
+
# Override with a custom directory
|
|
94
|
+
exemplar skills pull refund-policy --dest ./my-skills
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Set a default via `EXEMPLAR_SKILLS_TARGET=cursor-global`.
|
|
98
|
+
|
|
70
99
|
### Prompts
|
|
71
100
|
|
|
72
101
|
`list`, `get`, `search`, `create`, `run`, `publish`, `delete`
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Shared CLI helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from exemplar_core.memory.models import MemoryScopes
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def add_scope_arguments(parser: argparse.ArgumentParser) -> None:
|
|
11
|
+
"""Add memory scope flags (at least one required for most memory commands)."""
|
|
12
|
+
scope = parser.add_argument_group(
|
|
13
|
+
"memory scope",
|
|
14
|
+
"Scope memories to a user, agent, session, or app. At least one scope flag is "
|
|
15
|
+
"required for add, list, search, recall, update, delete, and delete-bulk.",
|
|
16
|
+
)
|
|
17
|
+
scope.add_argument(
|
|
18
|
+
"--user-id",
|
|
19
|
+
dest="user_id",
|
|
20
|
+
metavar="ID",
|
|
21
|
+
help="End-user identifier (e.g. u123)",
|
|
22
|
+
)
|
|
23
|
+
scope.add_argument(
|
|
24
|
+
"--agent-id",
|
|
25
|
+
dest="agent_id",
|
|
26
|
+
metavar="ID",
|
|
27
|
+
help="Agent identifier within your org",
|
|
28
|
+
)
|
|
29
|
+
scope.add_argument(
|
|
30
|
+
"--session-id",
|
|
31
|
+
dest="session_id",
|
|
32
|
+
metavar="ID",
|
|
33
|
+
help="Conversation or eval session identifier",
|
|
34
|
+
)
|
|
35
|
+
scope.add_argument(
|
|
36
|
+
"--app-id",
|
|
37
|
+
dest="app_id",
|
|
38
|
+
metavar="ID",
|
|
39
|
+
help="Source application identifier",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def scopes_from_args(args: argparse.Namespace) -> MemoryScopes:
|
|
44
|
+
return MemoryScopes(
|
|
45
|
+
user_id=args.user_id,
|
|
46
|
+
agent_id=args.agent_id,
|
|
47
|
+
session_id=args.session_id,
|
|
48
|
+
app_id=args.app_id,
|
|
49
|
+
)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Resolve local skill install destinations for Cursor, Claude, and custom paths."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
SkillTargetId = Literal["cursor", "cursor-global", "claude", "claude-global"]
|
|
12
|
+
|
|
13
|
+
DEFAULT_TARGET_ID: SkillTargetId = "cursor"
|
|
14
|
+
CONFIRM_TARGETS = frozenset({"claude", "claude-global"})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class SkillTarget:
|
|
19
|
+
target_id: SkillTargetId
|
|
20
|
+
agent: str
|
|
21
|
+
scope: str
|
|
22
|
+
relative: bool
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def label(self) -> str:
|
|
26
|
+
return f"{self.agent} ({self.scope})"
|
|
27
|
+
|
|
28
|
+
def resolve_path(self) -> Path:
|
|
29
|
+
if self.target_id == "cursor":
|
|
30
|
+
return Path(".cursor") / "skills"
|
|
31
|
+
if self.target_id == "cursor-global":
|
|
32
|
+
return Path.home() / ".cursor" / "skills"
|
|
33
|
+
if self.target_id == "claude":
|
|
34
|
+
return Path(".claude") / "skills"
|
|
35
|
+
if self.target_id == "claude-global":
|
|
36
|
+
return Path.home() / ".claude" / "skills"
|
|
37
|
+
raise ValueError(f"unknown target: {self.target_id}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
TARGETS: dict[SkillTargetId, SkillTarget] = {
|
|
41
|
+
"cursor": SkillTarget("cursor", "Cursor", "project", relative=True),
|
|
42
|
+
"cursor-global": SkillTarget("cursor-global", "Cursor", "global", relative=False),
|
|
43
|
+
"claude": SkillTarget("claude", "Claude Code", "project", relative=True),
|
|
44
|
+
"claude-global": SkillTarget("claude-global", "Claude Code", "global", relative=False),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _env_target() -> SkillTargetId | None:
|
|
49
|
+
raw = (os.environ.get("EXEMPLAR_SKILLS_TARGET") or "").strip().lower()
|
|
50
|
+
if not raw:
|
|
51
|
+
return None
|
|
52
|
+
if raw in ("cursor", "cursor-global", "claude", "claude-global"):
|
|
53
|
+
return raw # type: ignore[return-value]
|
|
54
|
+
if raw == "global":
|
|
55
|
+
return "cursor-global"
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"invalid EXEMPLAR_SKILLS_TARGET={raw!r}; "
|
|
58
|
+
"use cursor, cursor-global, claude, or claude-global"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _agent_hint_present(target_id: SkillTargetId) -> bool:
|
|
63
|
+
if target_id.startswith("cursor"):
|
|
64
|
+
home = Path.home() / ".cursor"
|
|
65
|
+
return home.is_dir() or (Path.cwd() / ".cursor").is_dir()
|
|
66
|
+
if target_id.startswith("claude"):
|
|
67
|
+
home = Path.home() / ".claude"
|
|
68
|
+
return home.is_dir() or (Path.cwd() / ".claude").is_dir()
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def detect_available_targets() -> list[SkillTargetId]:
|
|
73
|
+
"""Targets whose agent directories appear to exist on this machine."""
|
|
74
|
+
available: list[SkillTargetId] = []
|
|
75
|
+
for target_id in TARGETS:
|
|
76
|
+
if _agent_hint_present(target_id):
|
|
77
|
+
available.append(target_id)
|
|
78
|
+
return available or [DEFAULT_TARGET_ID]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _is_interactive() -> bool:
|
|
82
|
+
if os.environ.get("EXEMPLAR_SKILLS_INTERACTIVE", "").lower() in ("1", "true", "yes"):
|
|
83
|
+
return True
|
|
84
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _confirm(message: str) -> bool:
|
|
88
|
+
try:
|
|
89
|
+
answer = input(f"{message} [y/N]: ").strip().lower()
|
|
90
|
+
except EOFError:
|
|
91
|
+
return False
|
|
92
|
+
return answer in ("y", "yes")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _pick_target_interactively() -> SkillTargetId:
|
|
96
|
+
options = detect_available_targets()
|
|
97
|
+
if len(options) == 1:
|
|
98
|
+
return options[0]
|
|
99
|
+
|
|
100
|
+
print("Where should skills be installed?\n")
|
|
101
|
+
for index, target_id in enumerate(options, start=1):
|
|
102
|
+
target = TARGETS[target_id]
|
|
103
|
+
path = target.resolve_path()
|
|
104
|
+
print(f" [{index}] {target.label:<22} {path}")
|
|
105
|
+
print(f" [0] {TARGETS[DEFAULT_TARGET_ID].label:<22} {TARGETS[DEFAULT_TARGET_ID].resolve_path()} (default)")
|
|
106
|
+
|
|
107
|
+
default_choice = "0"
|
|
108
|
+
if DEFAULT_TARGET_ID in options:
|
|
109
|
+
default_choice = str(options.index(DEFAULT_TARGET_ID) + 1)
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
raw = input(f"\nChoose destination [{default_choice}]: ").strip()
|
|
113
|
+
except EOFError:
|
|
114
|
+
return DEFAULT_TARGET_ID
|
|
115
|
+
|
|
116
|
+
if not raw:
|
|
117
|
+
if default_choice == "0":
|
|
118
|
+
return DEFAULT_TARGET_ID
|
|
119
|
+
return options[int(default_choice) - 1]
|
|
120
|
+
|
|
121
|
+
if raw == "0":
|
|
122
|
+
return DEFAULT_TARGET_ID
|
|
123
|
+
if raw.isdigit() and 1 <= int(raw) <= len(options):
|
|
124
|
+
return options[int(raw) - 1]
|
|
125
|
+
|
|
126
|
+
lowered = raw.lower()
|
|
127
|
+
if lowered in TARGETS:
|
|
128
|
+
return lowered # type: ignore[return-value]
|
|
129
|
+
return DEFAULT_TARGET_ID
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _normalize_legacy_global(
|
|
133
|
+
*,
|
|
134
|
+
target: SkillTargetId | None,
|
|
135
|
+
global_flag: bool,
|
|
136
|
+
) -> SkillTargetId | None:
|
|
137
|
+
if target is not None:
|
|
138
|
+
return target
|
|
139
|
+
if global_flag:
|
|
140
|
+
return "cursor-global"
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def resolve_skill_target(
|
|
145
|
+
*,
|
|
146
|
+
dest: str | None = None,
|
|
147
|
+
target: str | None = None,
|
|
148
|
+
global_flag: bool = False,
|
|
149
|
+
yes: bool = False,
|
|
150
|
+
no_input: bool = False,
|
|
151
|
+
interactive: bool = False,
|
|
152
|
+
) -> tuple[Path, SkillTarget | None]:
|
|
153
|
+
"""
|
|
154
|
+
Resolve pull/init destination.
|
|
155
|
+
|
|
156
|
+
Returns (path, target_meta). target_meta is None when `dest` is explicit.
|
|
157
|
+
"""
|
|
158
|
+
if dest:
|
|
159
|
+
return Path(dest), None
|
|
160
|
+
|
|
161
|
+
target_id: SkillTargetId | None
|
|
162
|
+
try:
|
|
163
|
+
target_id = _env_target()
|
|
164
|
+
except ValueError as exc:
|
|
165
|
+
raise SystemExit(str(exc)) from exc
|
|
166
|
+
|
|
167
|
+
if target:
|
|
168
|
+
lowered = target.strip().lower()
|
|
169
|
+
if lowered == "global":
|
|
170
|
+
lowered = "cursor-global"
|
|
171
|
+
if lowered not in TARGETS:
|
|
172
|
+
raise SystemExit(
|
|
173
|
+
f"unknown --target {target!r}; choose from: cursor, cursor-global, claude, claude-global"
|
|
174
|
+
)
|
|
175
|
+
target_id = lowered # type: ignore[assignment]
|
|
176
|
+
|
|
177
|
+
target_id = _normalize_legacy_global(target=target_id, global_flag=global_flag)
|
|
178
|
+
|
|
179
|
+
if target_id is None and interactive and _is_interactive() and not no_input:
|
|
180
|
+
target_id = _pick_target_interactively()
|
|
181
|
+
elif target_id is None:
|
|
182
|
+
target_id = DEFAULT_TARGET_ID
|
|
183
|
+
|
|
184
|
+
meta = TARGETS[target_id]
|
|
185
|
+
path = meta.resolve_path()
|
|
186
|
+
|
|
187
|
+
if target_id in CONFIRM_TARGETS and not yes:
|
|
188
|
+
if no_input:
|
|
189
|
+
raise SystemExit(
|
|
190
|
+
f"{meta.label} requires confirmation; re-run with --yes or use --target cursor"
|
|
191
|
+
)
|
|
192
|
+
if not _is_interactive():
|
|
193
|
+
raise SystemExit(
|
|
194
|
+
f"{meta.label} ({path}) requires confirmation; re-run with --yes in non-interactive mode"
|
|
195
|
+
)
|
|
196
|
+
if not _confirm(f"Install skills to {path} for {meta.agent}?"):
|
|
197
|
+
raise SystemExit("cancelled")
|
|
198
|
+
|
|
199
|
+
return path, meta
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def format_destination_banner(path: Path, meta: SkillTarget | None) -> str:
|
|
203
|
+
if meta is None:
|
|
204
|
+
return f"Destination: {path}"
|
|
205
|
+
return f"Destination: {meta.label} → {path}"
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Exemplar CLI entrypoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from exemplar_cli.doctor import main as doctor_main
|
|
9
|
+
from exemplar_cli.memory import main as memory_main
|
|
10
|
+
from exemplar_cli.prompts import main as prompts_main
|
|
11
|
+
from exemplar_cli.skills import main as skills_main
|
|
12
|
+
|
|
13
|
+
_EPILOG = """
|
|
14
|
+
Commands:
|
|
15
|
+
skills Manage org agent skills (pull to Cursor/Claude, push from disk)
|
|
16
|
+
prompts Manage versioned prompt templates (create, run, publish)
|
|
17
|
+
memory Manage long-term agent memory (add, search, recall)
|
|
18
|
+
doctor Verify install, PATH, and EXEMPLAR_API_KEY
|
|
19
|
+
|
|
20
|
+
Examples:
|
|
21
|
+
exemplar doctor
|
|
22
|
+
exemplar skills pull refund-policy
|
|
23
|
+
exemplar prompts list
|
|
24
|
+
exemplar memory add "User prefers bullets" --user-id u1
|
|
25
|
+
|
|
26
|
+
Run `exemplar <command> --help` for full subcommand reference.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _build_root_parser() -> argparse.ArgumentParser:
|
|
31
|
+
parser = argparse.ArgumentParser(
|
|
32
|
+
prog="exemplar",
|
|
33
|
+
description=(
|
|
34
|
+
"Exemplar terminal CLI for skills, prompts, and memory. "
|
|
35
|
+
"Requires EXEMPLAR_API_KEY (run `exemplar doctor` to verify setup)."
|
|
36
|
+
),
|
|
37
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
38
|
+
epilog=_EPILOG,
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"command",
|
|
42
|
+
nargs="?",
|
|
43
|
+
choices=["skills", "memory", "prompts", "doctor"],
|
|
44
|
+
help="Command group to run",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument("args", nargs=argparse.REMAINDER, help=argparse.SUPPRESS)
|
|
47
|
+
return parser
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def main(argv: list[str] | None = None) -> int:
|
|
51
|
+
raw = list(argv if argv is not None else sys.argv[1:])
|
|
52
|
+
if raw and raw[0] in ("skills", "memory", "prompts", "doctor"):
|
|
53
|
+
if raw[0] == "skills":
|
|
54
|
+
return skills_main(raw[1:])
|
|
55
|
+
if raw[0] == "memory":
|
|
56
|
+
return memory_main(raw[1:])
|
|
57
|
+
if raw[0] == "prompts":
|
|
58
|
+
return prompts_main(raw[1:])
|
|
59
|
+
if raw[0] == "doctor":
|
|
60
|
+
return doctor_main(raw[1:])
|
|
61
|
+
|
|
62
|
+
parser = _build_root_parser()
|
|
63
|
+
args = parser.parse_args(raw)
|
|
64
|
+
if args.command is None:
|
|
65
|
+
parser.print_help()
|
|
66
|
+
return 2
|
|
67
|
+
return main([args.command, *args.args])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""CLI: exemplar memory — manage long-term memory from the terminal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import asdict
|
|
9
|
+
|
|
10
|
+
from exemplar_core import memory_from_env
|
|
11
|
+
from exemplar_cli._scopes import add_scope_arguments, scopes_from_args
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _memory(args: argparse.Namespace):
|
|
15
|
+
scopes = scopes_from_args(args)
|
|
16
|
+
return memory_from_env(
|
|
17
|
+
user_id=scopes.user_id,
|
|
18
|
+
agent_id=scopes.agent_id,
|
|
19
|
+
session_id=scopes.session_id,
|
|
20
|
+
app_id=scopes.app_id,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _record_dict(record) -> dict:
|
|
25
|
+
data = asdict(record)
|
|
26
|
+
if record.scopes is not None:
|
|
27
|
+
data["scopes"] = record.scopes.to_api_dict()
|
|
28
|
+
return data
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _cmd_add(args: argparse.Namespace) -> int:
|
|
32
|
+
try:
|
|
33
|
+
result = _memory(args).add(args.content, memory_type=args.type)
|
|
34
|
+
except ValueError as exc:
|
|
35
|
+
print(str(exc), file=sys.stderr)
|
|
36
|
+
return 1
|
|
37
|
+
if args.json:
|
|
38
|
+
print(json.dumps(result, indent=2))
|
|
39
|
+
else:
|
|
40
|
+
print(f"Added memory {result.get('memoryId', '')}")
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _cmd_list(args: argparse.Namespace) -> int:
|
|
45
|
+
records = _memory(args).list(
|
|
46
|
+
skip=args.skip,
|
|
47
|
+
limit=args.limit,
|
|
48
|
+
memory_type=args.type,
|
|
49
|
+
)
|
|
50
|
+
if args.json:
|
|
51
|
+
print(json.dumps([_record_dict(r) for r in records], indent=2))
|
|
52
|
+
return 0
|
|
53
|
+
for record in records:
|
|
54
|
+
print(f"{record.memory_id}\t[{record.memory_type}]\t{record.content}")
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _cmd_get(args: argparse.Namespace) -> int:
|
|
59
|
+
record = _memory(args).get(args.memory_id)
|
|
60
|
+
if args.json:
|
|
61
|
+
print(json.dumps(_record_dict(record), indent=2))
|
|
62
|
+
return 0
|
|
63
|
+
print(record.content)
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _cmd_search(args: argparse.Namespace) -> int:
|
|
68
|
+
records = _memory(args).search(
|
|
69
|
+
args.query,
|
|
70
|
+
top_k=args.top_k,
|
|
71
|
+
threshold=args.threshold,
|
|
72
|
+
)
|
|
73
|
+
if args.json:
|
|
74
|
+
print(json.dumps([_record_dict(r) for r in records], indent=2))
|
|
75
|
+
return 0
|
|
76
|
+
for record in records:
|
|
77
|
+
score = f"{record.score:.2f}\t" if record.score is not None else ""
|
|
78
|
+
print(f"{score}{record.memory_id}\t[{record.memory_type}]\t{record.content}")
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _cmd_recall(args: argparse.Namespace) -> int:
|
|
83
|
+
text = _memory(args).recall(
|
|
84
|
+
args.query,
|
|
85
|
+
top_k=args.top_k,
|
|
86
|
+
threshold=args.threshold,
|
|
87
|
+
)
|
|
88
|
+
print(text)
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _cmd_update(args: argparse.Namespace) -> int:
|
|
93
|
+
if args.content is None and args.type is None:
|
|
94
|
+
print("provide --content and/or --type", file=sys.stderr)
|
|
95
|
+
return 1
|
|
96
|
+
try:
|
|
97
|
+
record = _memory(args).update(
|
|
98
|
+
args.memory_id,
|
|
99
|
+
content=args.content,
|
|
100
|
+
memory_type=args.type,
|
|
101
|
+
)
|
|
102
|
+
except ValueError as exc:
|
|
103
|
+
print(str(exc), file=sys.stderr)
|
|
104
|
+
return 1
|
|
105
|
+
if args.json:
|
|
106
|
+
print(json.dumps(_record_dict(record), indent=2))
|
|
107
|
+
else:
|
|
108
|
+
print(f"Updated {record.memory_id}")
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _cmd_delete(args: argparse.Namespace) -> int:
|
|
113
|
+
_memory(args).delete(args.memory_id)
|
|
114
|
+
print(f"Deleted {args.memory_id}")
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _cmd_delete_bulk(args: argparse.Namespace) -> int:
|
|
119
|
+
try:
|
|
120
|
+
deleted = _memory(args).delete_bulk(memory_type=args.type)
|
|
121
|
+
except ValueError as exc:
|
|
122
|
+
print(str(exc), file=sys.stderr)
|
|
123
|
+
return 1
|
|
124
|
+
print(f"Deleted {deleted} memory(s)")
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
129
|
+
parser = argparse.ArgumentParser(
|
|
130
|
+
prog="exemplar memory",
|
|
131
|
+
description=(
|
|
132
|
+
"Manage long-term agent memory in Exemplar — add facts and preferences, "
|
|
133
|
+
"search, recall formatted context for prompts, update, and delete."
|
|
134
|
+
),
|
|
135
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
136
|
+
epilog=(
|
|
137
|
+
"Examples:\n"
|
|
138
|
+
" exemplar memory add \"User prefers bullets\" --user-id u1 --type preference\n"
|
|
139
|
+
" exemplar memory search \"formatting\" --user-id u1\n"
|
|
140
|
+
" exemplar memory recall \"how should I format answers?\" --user-id u1\n"
|
|
141
|
+
" exemplar memory delete-bulk --user-id u1 --type fact\n"
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
|
|
145
|
+
|
|
146
|
+
add_p = sub.add_parser(
|
|
147
|
+
"add",
|
|
148
|
+
help="Store a new memory record",
|
|
149
|
+
description="Add a memory string scoped to user, agent, session, or app.",
|
|
150
|
+
)
|
|
151
|
+
add_p.add_argument("content", metavar="TEXT", help="Memory content to store")
|
|
152
|
+
add_p.add_argument(
|
|
153
|
+
"--type",
|
|
154
|
+
default="fact",
|
|
155
|
+
dest="type",
|
|
156
|
+
metavar="TYPE",
|
|
157
|
+
help="Memory type label (default: fact)",
|
|
158
|
+
)
|
|
159
|
+
add_p.add_argument("--json", action="store_true", help="Output API response as JSON")
|
|
160
|
+
add_scope_arguments(add_p)
|
|
161
|
+
add_p.set_defaults(func=_cmd_add)
|
|
162
|
+
|
|
163
|
+
list_p = sub.add_parser(
|
|
164
|
+
"list",
|
|
165
|
+
help="List memories for the given scope",
|
|
166
|
+
description="List memory records matching scope filters.",
|
|
167
|
+
)
|
|
168
|
+
list_p.add_argument("--type", metavar="TYPE", help="Filter by memory type")
|
|
169
|
+
list_p.add_argument("--skip", type=int, default=0, help="Skip first N records (default: 0)")
|
|
170
|
+
list_p.add_argument("--limit", type=int, default=100, help="Maximum results (default: 100)")
|
|
171
|
+
list_p.add_argument("--json", action="store_true", help="Output JSON instead of TSV")
|
|
172
|
+
add_scope_arguments(list_p)
|
|
173
|
+
list_p.set_defaults(func=_cmd_list)
|
|
174
|
+
|
|
175
|
+
get_p = sub.add_parser(
|
|
176
|
+
"get",
|
|
177
|
+
help="Fetch a memory by ID",
|
|
178
|
+
description="Retrieve a single memory record by memory_id.",
|
|
179
|
+
)
|
|
180
|
+
get_p.add_argument("memory_id", metavar="ID", help="Memory ID")
|
|
181
|
+
get_p.add_argument("--json", action="store_true", help="Output full record as JSON")
|
|
182
|
+
add_scope_arguments(get_p)
|
|
183
|
+
get_p.set_defaults(func=_cmd_get)
|
|
184
|
+
|
|
185
|
+
search_p = sub.add_parser(
|
|
186
|
+
"search",
|
|
187
|
+
help="Semantic search over memories",
|
|
188
|
+
description="Search scoped memories by natural-language query.",
|
|
189
|
+
)
|
|
190
|
+
search_p.add_argument("query", metavar="QUERY", help="Search query")
|
|
191
|
+
search_p.add_argument("--top-k", type=int, default=10, help="Maximum results (default: 10)")
|
|
192
|
+
search_p.add_argument(
|
|
193
|
+
"--threshold",
|
|
194
|
+
type=float,
|
|
195
|
+
default=0.1,
|
|
196
|
+
help="Minimum similarity score 0–1 (default: 0.1)",
|
|
197
|
+
)
|
|
198
|
+
search_p.add_argument("--json", action="store_true", help="Output JSON instead of TSV")
|
|
199
|
+
add_scope_arguments(search_p)
|
|
200
|
+
search_p.set_defaults(func=_cmd_search)
|
|
201
|
+
|
|
202
|
+
recall_p = sub.add_parser(
|
|
203
|
+
"recall",
|
|
204
|
+
help="Print formatted memory context for a query",
|
|
205
|
+
description=(
|
|
206
|
+
"Search and format top memories as text suitable for injecting into "
|
|
207
|
+
"system prompts or agent instructions."
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
recall_p.add_argument("query", metavar="QUERY", help="Question or topic to recall context for")
|
|
211
|
+
recall_p.add_argument("--top-k", type=int, default=5, help="Maximum memories to include (default: 5)")
|
|
212
|
+
recall_p.add_argument(
|
|
213
|
+
"--threshold",
|
|
214
|
+
type=float,
|
|
215
|
+
default=0.1,
|
|
216
|
+
help="Minimum similarity score 0–1 (default: 0.1)",
|
|
217
|
+
)
|
|
218
|
+
add_scope_arguments(recall_p)
|
|
219
|
+
recall_p.set_defaults(func=_cmd_recall)
|
|
220
|
+
|
|
221
|
+
update_p = sub.add_parser(
|
|
222
|
+
"update",
|
|
223
|
+
help="Update an existing memory",
|
|
224
|
+
description="Change content and/or type of a memory record.",
|
|
225
|
+
)
|
|
226
|
+
update_p.add_argument("memory_id", metavar="ID", help="Memory ID")
|
|
227
|
+
update_p.add_argument("--content", metavar="TEXT", help="New memory content")
|
|
228
|
+
update_p.add_argument("--type", metavar="TYPE", help="New memory type")
|
|
229
|
+
update_p.add_argument("--json", action="store_true", help="Output updated record as JSON")
|
|
230
|
+
add_scope_arguments(update_p)
|
|
231
|
+
update_p.set_defaults(func=_cmd_update)
|
|
232
|
+
|
|
233
|
+
delete_p = sub.add_parser(
|
|
234
|
+
"delete",
|
|
235
|
+
help="Delete a memory by ID",
|
|
236
|
+
description="Remove a single memory record.",
|
|
237
|
+
)
|
|
238
|
+
delete_p.add_argument("memory_id", metavar="ID", help="Memory ID")
|
|
239
|
+
add_scope_arguments(delete_p)
|
|
240
|
+
delete_p.set_defaults(func=_cmd_delete)
|
|
241
|
+
|
|
242
|
+
bulk_p = sub.add_parser(
|
|
243
|
+
"delete-bulk",
|
|
244
|
+
help="Delete many memories matching scope filters",
|
|
245
|
+
description=(
|
|
246
|
+
"Bulk-delete memories for the given scope. Requires at least one scope flag. "
|
|
247
|
+
"Optionally filter by --type."
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
bulk_p.add_argument("--type", metavar="TYPE", help="Only delete memories of this type")
|
|
251
|
+
add_scope_arguments(bulk_p)
|
|
252
|
+
bulk_p.set_defaults(func=_cmd_delete_bulk)
|
|
253
|
+
|
|
254
|
+
return parser
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def main(argv: list[str] | None = None) -> int:
|
|
258
|
+
parser = build_parser()
|
|
259
|
+
args = parser.parse_args(argv)
|
|
260
|
+
return int(args.func(args))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
raise SystemExit(main())
|