memoryhub-cli 0.1.1__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.
- memoryhub_cli/__init__.py +3 -0
- memoryhub_cli/config.py +41 -0
- memoryhub_cli/main.py +479 -0
- memoryhub_cli/project_config.py +417 -0
- memoryhub_cli-0.1.1.dist-info/METADATA +83 -0
- memoryhub_cli-0.1.1.dist-info/RECORD +8 -0
- memoryhub_cli-0.1.1.dist-info/WHEEL +4 -0
- memoryhub_cli-0.1.1.dist-info/entry_points.txt +2 -0
memoryhub_cli/config.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Configuration management for MemoryHub CLI."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
CONFIG_DIR = Path.home() / ".config" / "memoryhub"
|
|
7
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_config() -> dict:
|
|
11
|
+
"""Load config from disk. Returns empty dict if not found."""
|
|
12
|
+
if not CONFIG_FILE.exists():
|
|
13
|
+
return {}
|
|
14
|
+
return json.loads(CONFIG_FILE.read_text())
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def save_config(config: dict) -> None:
|
|
18
|
+
"""Save config to disk."""
|
|
19
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
CONFIG_FILE.write_text(json.dumps(config, indent=2) + "\n")
|
|
21
|
+
# Restrict permissions — contains secrets
|
|
22
|
+
CONFIG_FILE.chmod(0o600)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_connection_params() -> dict:
|
|
26
|
+
"""Get connection parameters, preferring env vars over config file.
|
|
27
|
+
|
|
28
|
+
Required keys: url, auth_url, client_id, client_secret.
|
|
29
|
+
Env vars: MEMORYHUB_URL, MEMORYHUB_AUTH_URL, MEMORYHUB_CLIENT_ID, MEMORYHUB_CLIENT_SECRET.
|
|
30
|
+
"""
|
|
31
|
+
import os
|
|
32
|
+
|
|
33
|
+
config = load_config()
|
|
34
|
+
return {
|
|
35
|
+
"url": os.environ.get("MEMORYHUB_URL", config.get("url", "")),
|
|
36
|
+
"auth_url": os.environ.get("MEMORYHUB_AUTH_URL", config.get("auth_url", "")),
|
|
37
|
+
"client_id": os.environ.get("MEMORYHUB_CLIENT_ID", config.get("client_id", "")),
|
|
38
|
+
"client_secret": os.environ.get(
|
|
39
|
+
"MEMORYHUB_CLIENT_SECRET", config.get("client_secret", "")
|
|
40
|
+
),
|
|
41
|
+
}
|
memoryhub_cli/main.py
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
"""MemoryHub CLI — terminal interface for centralized agent memory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from memoryhub import CONFIG_FILENAME, ConfigError, load_project_config
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from memoryhub_cli.config import get_connection_params, save_config
|
|
15
|
+
from memoryhub_cli.project_config import (
|
|
16
|
+
InitChoices,
|
|
17
|
+
LoadingPattern,
|
|
18
|
+
SessionShape,
|
|
19
|
+
build_project_config,
|
|
20
|
+
rewrite_rule_file,
|
|
21
|
+
suggest_pattern,
|
|
22
|
+
write_init_files,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(
|
|
26
|
+
name="memoryhub",
|
|
27
|
+
help="CLI client for MemoryHub — centralized, governed memory for AI agents.",
|
|
28
|
+
no_args_is_help=True,
|
|
29
|
+
)
|
|
30
|
+
config_app = typer.Typer(
|
|
31
|
+
name="config",
|
|
32
|
+
help="Manage project-level MemoryHub configuration (.memoryhub.yaml).",
|
|
33
|
+
no_args_is_help=True,
|
|
34
|
+
)
|
|
35
|
+
app.add_typer(config_app, name="config")
|
|
36
|
+
console = Console()
|
|
37
|
+
err_console = Console(stderr=True)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _get_client():
|
|
41
|
+
"""Create a MemoryHubClient from config/env."""
|
|
42
|
+
from memoryhub import MemoryHubClient
|
|
43
|
+
|
|
44
|
+
params = get_connection_params()
|
|
45
|
+
missing = [k for k, v in params.items() if not v]
|
|
46
|
+
if missing:
|
|
47
|
+
err_console.print(
|
|
48
|
+
f"[red]Missing configuration: {', '.join(missing)}[/red]\n"
|
|
49
|
+
"Run [bold]memoryhub login[/bold] or set environment variables."
|
|
50
|
+
)
|
|
51
|
+
raise typer.Exit(1)
|
|
52
|
+
|
|
53
|
+
return MemoryHubClient(
|
|
54
|
+
url=params["url"],
|
|
55
|
+
auth_url=params["auth_url"],
|
|
56
|
+
client_id=params["client_id"],
|
|
57
|
+
client_secret=params["client_secret"],
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _run(coro):
|
|
62
|
+
"""Run an async coroutine."""
|
|
63
|
+
return asyncio.run(coro)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.command()
|
|
67
|
+
def login(
|
|
68
|
+
url: str = typer.Option(..., prompt="MemoryHub MCP URL", help="MCP server URL"),
|
|
69
|
+
auth_url: str = typer.Option(..., prompt="Auth service URL", help="OAuth 2.1 auth URL"),
|
|
70
|
+
client_id: str = typer.Option(..., prompt="Client ID", help="OAuth client ID"),
|
|
71
|
+
client_secret: str = typer.Option(
|
|
72
|
+
..., prompt="Client secret", hide_input=True, help="OAuth client secret"
|
|
73
|
+
),
|
|
74
|
+
):
|
|
75
|
+
"""Configure connection to a MemoryHub instance.
|
|
76
|
+
|
|
77
|
+
Credentials are stored in ~/.config/memoryhub/config.json (mode 600).
|
|
78
|
+
Environment variables (MEMORYHUB_URL, etc.) take precedence over stored config.
|
|
79
|
+
"""
|
|
80
|
+
save_config({
|
|
81
|
+
"url": url,
|
|
82
|
+
"auth_url": auth_url,
|
|
83
|
+
"client_id": client_id,
|
|
84
|
+
"client_secret": client_secret,
|
|
85
|
+
})
|
|
86
|
+
console.print("[green]Configuration saved.[/green]")
|
|
87
|
+
|
|
88
|
+
# Test connectivity
|
|
89
|
+
async def _test():
|
|
90
|
+
from memoryhub import MemoryHubClient
|
|
91
|
+
|
|
92
|
+
client = MemoryHubClient(
|
|
93
|
+
url=url, auth_url=auth_url,
|
|
94
|
+
client_id=client_id, client_secret=client_secret,
|
|
95
|
+
)
|
|
96
|
+
async with client:
|
|
97
|
+
result = await client.search("test", max_results=1)
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
_run(_test())
|
|
102
|
+
console.print("[green]Connection verified.[/green]")
|
|
103
|
+
except Exception as exc:
|
|
104
|
+
err_console.print(f"[yellow]Warning: connection test failed: {exc}[/yellow]")
|
|
105
|
+
err_console.print("Credentials saved anyway. Check URL and credentials.")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.command()
|
|
109
|
+
def search(
|
|
110
|
+
query: str = typer.Argument(..., help="Search query"),
|
|
111
|
+
scope: str | None = typer.Option(None, "--scope", "-s", help="Filter by scope"),
|
|
112
|
+
max_results: int = typer.Option(10, "--max", "-n", help="Maximum results"),
|
|
113
|
+
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
|
|
114
|
+
):
|
|
115
|
+
"""Search memories using semantic similarity."""
|
|
116
|
+
client = _get_client()
|
|
117
|
+
|
|
118
|
+
async def _do():
|
|
119
|
+
async with client:
|
|
120
|
+
return await client.search(
|
|
121
|
+
query, scope=scope, max_results=max_results,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
result = _run(_do())
|
|
125
|
+
|
|
126
|
+
if json_output:
|
|
127
|
+
console.print_json(result.model_dump_json())
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
if not result.results:
|
|
131
|
+
console.print("[dim]No results found.[/dim]")
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
table = Table(title=f"Search: {query}")
|
|
135
|
+
table.add_column("ID", style="dim", max_width=12)
|
|
136
|
+
table.add_column("Scope", style="cyan")
|
|
137
|
+
table.add_column("Weight", justify="right")
|
|
138
|
+
table.add_column("Score", justify="right")
|
|
139
|
+
table.add_column("Stub", max_width=60)
|
|
140
|
+
|
|
141
|
+
for mem in result.results:
|
|
142
|
+
score = f"{mem.relevance_score:.3f}" if mem.relevance_score else "-"
|
|
143
|
+
table.add_row(
|
|
144
|
+
str(mem.id)[:12],
|
|
145
|
+
mem.scope,
|
|
146
|
+
f"{mem.weight:.2f}",
|
|
147
|
+
score,
|
|
148
|
+
(mem.stub or mem.content)[:60],
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
console.print(table)
|
|
152
|
+
more = " (more available)" if result.has_more else ""
|
|
153
|
+
console.print(
|
|
154
|
+
f"[dim]{len(result.results)} of {result.total_matching} matching{more}[/dim]"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@app.command()
|
|
159
|
+
def read(
|
|
160
|
+
memory_id: str = typer.Argument(..., help="Memory UUID"),
|
|
161
|
+
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
|
|
162
|
+
):
|
|
163
|
+
"""Read a memory by ID."""
|
|
164
|
+
client = _get_client()
|
|
165
|
+
|
|
166
|
+
async def _do():
|
|
167
|
+
async with client:
|
|
168
|
+
return await client.read(memory_id)
|
|
169
|
+
|
|
170
|
+
memory = _run(_do())
|
|
171
|
+
|
|
172
|
+
if json_output:
|
|
173
|
+
console.print_json(memory.model_dump_json())
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
console.print(f"[bold]{memory.scope}[/bold] | v{memory.version} | weight {memory.weight:.2f}")
|
|
177
|
+
console.print(f"[dim]ID: {memory.id}[/dim]")
|
|
178
|
+
console.print(f"[dim]Owner: {memory.owner_id}[/dim]")
|
|
179
|
+
console.print()
|
|
180
|
+
console.print(memory.content)
|
|
181
|
+
|
|
182
|
+
if memory.branch_count:
|
|
183
|
+
console.print(
|
|
184
|
+
f"\n[dim]{memory.branch_count} branch(es). "
|
|
185
|
+
f"Search or read by ID to inspect them.[/dim]"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@app.command()
|
|
190
|
+
def write(
|
|
191
|
+
content: str = typer.Argument(None, help="Memory content (reads from stdin if omitted)"),
|
|
192
|
+
scope: str = typer.Option("user", "--scope", "-s", help="Memory scope"),
|
|
193
|
+
weight: float = typer.Option(0.7, "--weight", "-w", help="Priority weight 0.0-1.0"),
|
|
194
|
+
parent_id: str | None = typer.Option(None, "--parent", help="Parent memory ID"),
|
|
195
|
+
branch_type: str | None = typer.Option(None, "--branch-type", help="Branch type"),
|
|
196
|
+
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
|
|
197
|
+
):
|
|
198
|
+
"""Write a new memory.
|
|
199
|
+
|
|
200
|
+
Content can be passed as an argument or piped via stdin.
|
|
201
|
+
"""
|
|
202
|
+
if content is None:
|
|
203
|
+
if sys.stdin.isatty():
|
|
204
|
+
err_console.print("[red]Provide content as argument or pipe via stdin.[/red]")
|
|
205
|
+
raise typer.Exit(1)
|
|
206
|
+
content = sys.stdin.read().strip()
|
|
207
|
+
|
|
208
|
+
if not content:
|
|
209
|
+
err_console.print("[red]Content cannot be empty.[/red]")
|
|
210
|
+
raise typer.Exit(1)
|
|
211
|
+
|
|
212
|
+
client = _get_client()
|
|
213
|
+
|
|
214
|
+
async def _do():
|
|
215
|
+
async with client:
|
|
216
|
+
return await client.write(
|
|
217
|
+
content, scope=scope, weight=weight,
|
|
218
|
+
parent_id=parent_id, branch_type=branch_type,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
result = _run(_do())
|
|
222
|
+
|
|
223
|
+
if json_output:
|
|
224
|
+
console.print_json(result.model_dump_json())
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
mem = result.memory
|
|
228
|
+
console.print(f"[green]Memory created:[/green] {mem.id}")
|
|
229
|
+
console.print(f" Scope: {mem.scope} | Weight: {mem.weight:.2f} | Version: {mem.version}")
|
|
230
|
+
if result.curation.blocked:
|
|
231
|
+
console.print("[yellow]Note: curation pipeline blocked this write.[/yellow]")
|
|
232
|
+
elif result.curation.similar_count > 0:
|
|
233
|
+
console.print(
|
|
234
|
+
f"[dim]Curation: {result.curation.similar_count} similar memories found"
|
|
235
|
+
f" (nearest score: {result.curation.nearest_score:.3f})[/dim]"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@app.command()
|
|
240
|
+
def delete(
|
|
241
|
+
memory_id: str = typer.Argument(..., help="Memory UUID to delete"),
|
|
242
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
|
|
243
|
+
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
|
|
244
|
+
):
|
|
245
|
+
"""Soft-delete a memory and its version chain."""
|
|
246
|
+
if not force:
|
|
247
|
+
confirm = typer.confirm(f"Delete memory {memory_id} and all versions?")
|
|
248
|
+
if not confirm:
|
|
249
|
+
raise typer.Abort()
|
|
250
|
+
|
|
251
|
+
client = _get_client()
|
|
252
|
+
|
|
253
|
+
async def _do():
|
|
254
|
+
async with client:
|
|
255
|
+
return await client.delete(memory_id)
|
|
256
|
+
|
|
257
|
+
result = _run(_do())
|
|
258
|
+
|
|
259
|
+
if json_output:
|
|
260
|
+
console.print_json(result.model_dump_json())
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
console.print(
|
|
264
|
+
f"[green]Deleted:[/green] {result.total_deleted} nodes "
|
|
265
|
+
f"({result.versions_deleted} versions, {result.branches_deleted} branches)"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@app.command()
|
|
270
|
+
def history(
|
|
271
|
+
memory_id: str = typer.Argument(..., help="Memory UUID"),
|
|
272
|
+
max_versions: int = typer.Option(20, "--max", "-n", help="Maximum versions to show"),
|
|
273
|
+
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
|
|
274
|
+
):
|
|
275
|
+
"""Show version history for a memory."""
|
|
276
|
+
client = _get_client()
|
|
277
|
+
|
|
278
|
+
async def _do():
|
|
279
|
+
async with client:
|
|
280
|
+
return await client.get_history(memory_id, max_versions=max_versions)
|
|
281
|
+
|
|
282
|
+
result = _run(_do())
|
|
283
|
+
|
|
284
|
+
if json_output:
|
|
285
|
+
console.print_json(result.model_dump_json())
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
if not result.versions:
|
|
289
|
+
console.print("[dim]No version history found.[/dim]")
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
table = Table(title=f"History: {memory_id[:12]}...")
|
|
293
|
+
table.add_column("Version", justify="right")
|
|
294
|
+
table.add_column("Current", justify="center")
|
|
295
|
+
table.add_column("Created", style="dim")
|
|
296
|
+
table.add_column("Stub", max_width=60)
|
|
297
|
+
|
|
298
|
+
for v in result.versions:
|
|
299
|
+
current = "[green]Yes[/green]" if v.is_current else ""
|
|
300
|
+
created = str(v.created_at)[:19] if v.created_at else "-"
|
|
301
|
+
table.add_row(
|
|
302
|
+
f"v{v.version}",
|
|
303
|
+
current,
|
|
304
|
+
created,
|
|
305
|
+
(v.stub or v.content)[:60],
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
console.print(table)
|
|
309
|
+
if result.has_more:
|
|
310
|
+
console.print(
|
|
311
|
+
f"[dim]Showing {len(result.versions)} of {result.total_versions} versions[/dim]"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ── memoryhub config init / regenerate ───────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
_SHAPE_PROMPT = """\
|
|
319
|
+
What's this project's typical session shape?
|
|
320
|
+
1) One topic per session, narrow scope (focused)
|
|
321
|
+
2) Multiple topics per session, broad context needed (broad)
|
|
322
|
+
3) Sessions evolve — start narrow, may pivot (adaptive)\
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
_PATTERN_PROMPT = """\
|
|
326
|
+
How should memories load?
|
|
327
|
+
1) Eager — load at session start (best for broad)
|
|
328
|
+
2) Lazy — load after first user turn (best for focused)
|
|
329
|
+
3) Lazy + rebias on pivot (best for adaptive)
|
|
330
|
+
4) Just-in-time — never preload, search on demand\
|
|
331
|
+
"""
|
|
332
|
+
|
|
333
|
+
_FOCUS_PROMPT = """\
|
|
334
|
+
How should session focus be inferred?
|
|
335
|
+
1) Declared — agent will ask
|
|
336
|
+
2) Inferred from working directory
|
|
337
|
+
3) Inferred from first user turn
|
|
338
|
+
4) Auto (try inference, fall back to ask)\
|
|
339
|
+
"""
|
|
340
|
+
|
|
341
|
+
_CONTRADICTION_BLURB = """\
|
|
342
|
+
Cross-domain contradiction detection:
|
|
343
|
+
Focused mode loads only memories matching session topic. If you make
|
|
344
|
+
a decision in this session that contradicts a memory from a different
|
|
345
|
+
topic, the agent won't catch it. You can value this coverage over
|
|
346
|
+
token efficiency by switching to broad mode.\
|
|
347
|
+
"""
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
_SHAPE_BY_INDEX: dict[int, SessionShape] = {1: "focused", 2: "broad", 3: "adaptive"}
|
|
351
|
+
_PATTERN_BY_INDEX: dict[int, LoadingPattern] = {
|
|
352
|
+
1: "eager",
|
|
353
|
+
2: "lazy",
|
|
354
|
+
3: "lazy_with_rebias",
|
|
355
|
+
4: "jit",
|
|
356
|
+
}
|
|
357
|
+
_FOCUS_BY_INDEX = {1: "declared", 2: "directory", 3: "first_turn", 4: "auto"}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _prompt_choice(prompt_text: str, choices: dict, default: int) -> int:
|
|
361
|
+
"""Prompt for an integer in `choices`, defaulting to `default`."""
|
|
362
|
+
while True:
|
|
363
|
+
console.print(prompt_text)
|
|
364
|
+
raw = typer.prompt(f"Choice [{default}]", default=str(default))
|
|
365
|
+
try:
|
|
366
|
+
value = int(raw)
|
|
367
|
+
except ValueError:
|
|
368
|
+
err_console.print(f"[red]Not a number: {raw}[/red]")
|
|
369
|
+
continue
|
|
370
|
+
if value in choices:
|
|
371
|
+
return value
|
|
372
|
+
err_console.print(
|
|
373
|
+
f"[red]Pick one of: {', '.join(str(k) for k in choices)}[/red]"
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
@config_app.command("init")
|
|
378
|
+
def config_init(
|
|
379
|
+
project_dir: Path = typer.Option(
|
|
380
|
+
Path("."),
|
|
381
|
+
"--dir",
|
|
382
|
+
"-d",
|
|
383
|
+
help="Project directory (defaults to cwd).",
|
|
384
|
+
file_okay=False,
|
|
385
|
+
),
|
|
386
|
+
force: bool = typer.Option(
|
|
387
|
+
False,
|
|
388
|
+
"--force",
|
|
389
|
+
"-f",
|
|
390
|
+
help="Overwrite existing .memoryhub.yaml or generated rule file.",
|
|
391
|
+
),
|
|
392
|
+
):
|
|
393
|
+
"""Walk through project setup and write `.memoryhub.yaml` + the
|
|
394
|
+
generated `.claude/rules/memoryhub-loading.md` rule file."""
|
|
395
|
+
project_dir = project_dir.resolve()
|
|
396
|
+
console.print(f"[bold]Configuring MemoryHub for[/bold] {project_dir}\n")
|
|
397
|
+
|
|
398
|
+
shape_idx = _prompt_choice(_SHAPE_PROMPT, _SHAPE_BY_INDEX, default=1)
|
|
399
|
+
shape = _SHAPE_BY_INDEX[shape_idx]
|
|
400
|
+
|
|
401
|
+
suggested_pattern = suggest_pattern(shape)
|
|
402
|
+
pattern_default = next(
|
|
403
|
+
i for i, p in _PATTERN_BY_INDEX.items() if p == suggested_pattern
|
|
404
|
+
)
|
|
405
|
+
pattern_idx = _prompt_choice(_PATTERN_PROMPT, _PATTERN_BY_INDEX, default=pattern_default)
|
|
406
|
+
pattern = _PATTERN_BY_INDEX[pattern_idx]
|
|
407
|
+
|
|
408
|
+
focus_idx = _prompt_choice(_FOCUS_PROMPT, _FOCUS_BY_INDEX, default=4)
|
|
409
|
+
focus_source = _FOCUS_BY_INDEX[focus_idx]
|
|
410
|
+
|
|
411
|
+
console.print(f"\n{_CONTRADICTION_BLURB}\n")
|
|
412
|
+
keep_contradictions = typer.confirm(
|
|
413
|
+
"Keep contradiction detection across all domains?",
|
|
414
|
+
default=False,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
choices = InitChoices(
|
|
418
|
+
session_shape=shape,
|
|
419
|
+
pattern=pattern,
|
|
420
|
+
focus_source=focus_source,
|
|
421
|
+
cross_domain_contradiction_detection=keep_contradictions,
|
|
422
|
+
)
|
|
423
|
+
config = build_project_config(choices)
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
result = write_init_files(config, project_dir, overwrite=force)
|
|
427
|
+
except FileExistsError as exc:
|
|
428
|
+
err_console.print(f"[red]{exc}[/red]")
|
|
429
|
+
raise typer.Exit(1) from exc
|
|
430
|
+
|
|
431
|
+
console.print(f"\n[green]Wrote {result.yaml_path}[/green]")
|
|
432
|
+
console.print(f"[green]Wrote {result.rule_path}[/green]")
|
|
433
|
+
if result.legacy_backup is not None:
|
|
434
|
+
console.print(
|
|
435
|
+
f"[yellow]Backed up legacy rule to {result.legacy_backup}.[/yellow]\n"
|
|
436
|
+
f"Review and delete the .bak when you're satisfied with the new rule."
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
@config_app.command("regenerate")
|
|
441
|
+
def config_regenerate(
|
|
442
|
+
project_dir: Path = typer.Option(
|
|
443
|
+
Path("."),
|
|
444
|
+
"--dir",
|
|
445
|
+
"-d",
|
|
446
|
+
help="Project directory (defaults to cwd).",
|
|
447
|
+
file_okay=False,
|
|
448
|
+
),
|
|
449
|
+
):
|
|
450
|
+
"""Re-render `.claude/rules/memoryhub-loading.md` from `.memoryhub.yaml`.
|
|
451
|
+
|
|
452
|
+
Use this after editing the YAML by hand to refresh the rule file
|
|
453
|
+
without running the interactive prompt again.
|
|
454
|
+
"""
|
|
455
|
+
project_dir = project_dir.resolve()
|
|
456
|
+
yaml_path = project_dir / CONFIG_FILENAME
|
|
457
|
+
if not yaml_path.is_file():
|
|
458
|
+
err_console.print(
|
|
459
|
+
f"[red]No {CONFIG_FILENAME} in {project_dir}.[/red]\n"
|
|
460
|
+
"Run [bold]memoryhub config init[/bold] first."
|
|
461
|
+
)
|
|
462
|
+
raise typer.Exit(1)
|
|
463
|
+
|
|
464
|
+
try:
|
|
465
|
+
config = load_project_config(yaml_path)
|
|
466
|
+
except ConfigError as exc:
|
|
467
|
+
err_console.print(f"[red]{exc}[/red]")
|
|
468
|
+
raise typer.Exit(1) from exc
|
|
469
|
+
|
|
470
|
+
result = rewrite_rule_file(config, project_dir)
|
|
471
|
+
console.print(f"[green]Regenerated {result.rule_path}[/green]")
|
|
472
|
+
if result.legacy_backup is not None:
|
|
473
|
+
console.print(
|
|
474
|
+
f"[yellow]Backed up legacy rule to {result.legacy_backup}.[/yellow]"
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
if __name__ == "__main__":
|
|
479
|
+
app()
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
"""Project-config scaffolding for `memoryhub config init`.
|
|
2
|
+
|
|
3
|
+
Pure functions that build a :class:`memoryhub.ProjectConfig` from a set
|
|
4
|
+
of choices and render the corresponding `.memoryhub.yaml` and
|
|
5
|
+
`.claude/rules/memoryhub-loading.md` files.
|
|
6
|
+
|
|
7
|
+
The interactive prompting lives in :mod:`memoryhub_cli.main`; everything
|
|
8
|
+
in this module is testable without I/O of its own.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
import yaml
|
|
18
|
+
from memoryhub import (
|
|
19
|
+
CONFIG_FILENAME,
|
|
20
|
+
MemoryLoadingConfig,
|
|
21
|
+
ProjectConfig,
|
|
22
|
+
RetrievalDefaults,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# ── Choices captured from the interactive prompt ─────────────────────────────
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
SessionShape = Literal["focused", "broad", "adaptive"]
|
|
29
|
+
LoadingPattern = Literal["eager", "lazy", "lazy_with_rebias", "jit"]
|
|
30
|
+
FocusSource = Literal["declared", "directory", "first_turn", "auto"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class InitChoices:
|
|
35
|
+
"""Answers collected from the interactive prompt.
|
|
36
|
+
|
|
37
|
+
Decoupled from :class:`ProjectConfig` so the prompt and the schema
|
|
38
|
+
can evolve independently. ``session_shape`` is captured for the
|
|
39
|
+
rule-file template (it shapes the introductory prose) but maps onto
|
|
40
|
+
the ``memory_loading.mode`` field.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
session_shape: SessionShape
|
|
44
|
+
pattern: LoadingPattern
|
|
45
|
+
focus_source: FocusSource
|
|
46
|
+
cross_domain_contradiction_detection: bool
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── Defaults that the prompt suggests based on session shape ─────────────────
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_PATTERN_FOR_SHAPE: dict[SessionShape, LoadingPattern] = {
|
|
53
|
+
"focused": "lazy",
|
|
54
|
+
"broad": "eager",
|
|
55
|
+
"adaptive": "lazy_with_rebias",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
_MODE_FOR_SHAPE: dict[SessionShape, Literal["focused", "broad"]] = {
|
|
59
|
+
"focused": "focused",
|
|
60
|
+
"broad": "broad",
|
|
61
|
+
"adaptive": "focused",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def suggest_pattern(shape: SessionShape) -> LoadingPattern:
|
|
66
|
+
"""Return the recommended pattern for a given session shape."""
|
|
67
|
+
return _PATTERN_FOR_SHAPE[shape]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ── Schema construction ──────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def build_project_config(choices: InitChoices) -> ProjectConfig:
|
|
74
|
+
"""Translate user choices into a :class:`ProjectConfig`.
|
|
75
|
+
|
|
76
|
+
The ``session_focus_weight`` and Pattern E knobs use schema defaults;
|
|
77
|
+
the prompt does not surface them in v1 to keep the interaction
|
|
78
|
+
short. Users can edit ``.memoryhub.yaml`` directly to tune them and
|
|
79
|
+
re-run ``memoryhub config regenerate``.
|
|
80
|
+
"""
|
|
81
|
+
return ProjectConfig(
|
|
82
|
+
memory_loading=MemoryLoadingConfig(
|
|
83
|
+
mode=_MODE_FOR_SHAPE[choices.session_shape],
|
|
84
|
+
pattern=choices.pattern,
|
|
85
|
+
focus_source=choices.focus_source,
|
|
86
|
+
cross_domain_contradiction_detection=(
|
|
87
|
+
choices.cross_domain_contradiction_detection
|
|
88
|
+
),
|
|
89
|
+
),
|
|
90
|
+
retrieval_defaults=RetrievalDefaults(),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ── YAML serialization ───────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
_YAML_HEADER = (
|
|
98
|
+
"# MemoryHub project configuration\n"
|
|
99
|
+
"#\n"
|
|
100
|
+
"# Generated by `memoryhub config init`. Edit by hand and run\n"
|
|
101
|
+
"# `memoryhub config regenerate` to refresh the rule file.\n"
|
|
102
|
+
"#\n"
|
|
103
|
+
"# Schema reference: docs/agent-memory-ergonomics/design.md\n"
|
|
104
|
+
"\n"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def render_yaml(config: ProjectConfig) -> str:
|
|
109
|
+
"""Serialize a ProjectConfig to YAML with a generator banner."""
|
|
110
|
+
body = yaml.safe_dump(
|
|
111
|
+
config.model_dump(),
|
|
112
|
+
sort_keys=False,
|
|
113
|
+
default_flow_style=False,
|
|
114
|
+
)
|
|
115
|
+
return _YAML_HEADER + body
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ── Rule-file templates ──────────────────────────────────────────────────────
|
|
119
|
+
#
|
|
120
|
+
# The wording of these templates matters more than the YAML config — they are
|
|
121
|
+
# the actual instructions the consuming agent reads. Each template is fully
|
|
122
|
+
# self-contained: session start, during-session, memory hygiene, and
|
|
123
|
+
# contradiction handling. The pattern-specific session-start and
|
|
124
|
+
# during-session sections are the only parts that vary; hygiene and
|
|
125
|
+
# contradiction handling are shared.
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
_RULE_HEADER = """\
|
|
129
|
+
# MemoryHub Loading: {pattern_title}
|
|
130
|
+
|
|
131
|
+
This project uses MemoryHub for persistent, centralized agent memory across
|
|
132
|
+
conversations. You MUST use it.
|
|
133
|
+
|
|
134
|
+
This rule was generated by `memoryhub config init` from `.memoryhub.yaml`.
|
|
135
|
+
Re-run `memoryhub config regenerate` after editing the YAML to refresh this
|
|
136
|
+
file. Do not hand-edit this file directly — your changes will be overwritten.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
_PATTERN_BLOCKS: dict[LoadingPattern, str] = {
|
|
141
|
+
"eager": """\
|
|
142
|
+
## At session start
|
|
143
|
+
|
|
144
|
+
1. Call `register_session(api_key="<your-api-key>")` to authenticate.
|
|
145
|
+
2. Immediately call `search_memory(query="", mode="index", max_results=50)`
|
|
146
|
+
to load the full working set as lightweight stubs. The empty query plus
|
|
147
|
+
`mode="index"` returns headers for everything visible to your user.
|
|
148
|
+
3. Hold the returned working set in context for the entire session.
|
|
149
|
+
|
|
150
|
+
## During the session
|
|
151
|
+
|
|
152
|
+
- When a stub looks load-bearing for the current task, call `read_memory`
|
|
153
|
+
to expand it into full content.
|
|
154
|
+
- New writes (your own or another agent's) are NOT pushed automatically.
|
|
155
|
+
If a decision depends on the latest state, call `search_memory` again
|
|
156
|
+
rather than trusting the working set you loaded at session start.
|
|
157
|
+
""",
|
|
158
|
+
"lazy": """\
|
|
159
|
+
## At session start
|
|
160
|
+
|
|
161
|
+
Call `register_session(api_key="<your-api-key>")` to authenticate. Do NOT
|
|
162
|
+
call `search_memory` yet — your working set is empty until the first user
|
|
163
|
+
turn arrives.
|
|
164
|
+
|
|
165
|
+
## After the first user turn
|
|
166
|
+
|
|
167
|
+
Derive a 1-2 sentence summary of the user's intent from the opening
|
|
168
|
+
message. Call `search_memory(query=<summary>)`. Use the returned memories
|
|
169
|
+
as your working set for the session.
|
|
170
|
+
|
|
171
|
+
## During the session
|
|
172
|
+
|
|
173
|
+
- Trust your working set. Re-search only when the user explicitly
|
|
174
|
+
references a concept you don't have loaded.
|
|
175
|
+
- If the opening turn was vague ("can you take a look at this?"), your
|
|
176
|
+
working set may miss relevant memories. Watch for it and re-search with
|
|
177
|
+
a more specific query as soon as the topic firms up.
|
|
178
|
+
""",
|
|
179
|
+
"lazy_with_rebias": """\
|
|
180
|
+
## At session start
|
|
181
|
+
|
|
182
|
+
Call `register_session(api_key="<your-api-key>")` to authenticate. Do NOT
|
|
183
|
+
call `search_memory` yet.
|
|
184
|
+
|
|
185
|
+
## After the first user turn
|
|
186
|
+
|
|
187
|
+
Derive a 1-2 sentence summary of the user's intent from the opening
|
|
188
|
+
message. Call `search_memory(query=<summary>)`. Use the returned memories
|
|
189
|
+
as your working set for the session.
|
|
190
|
+
|
|
191
|
+
## During the session — watch for pivots
|
|
192
|
+
|
|
193
|
+
A pivot is any of:
|
|
194
|
+
|
|
195
|
+
1. **Subsystem change** — the user changes topic to a different area of
|
|
196
|
+
the project (e.g., from "deployment" to "UI", or from "MCP server" to
|
|
197
|
+
"SDK").
|
|
198
|
+
2. **Unknown concept** — the user references a project-specific term that
|
|
199
|
+
isn't in your working set.
|
|
200
|
+
3. **Explicit switch** — the user says "let's switch to...", "now let's
|
|
201
|
+
talk about...", or similar phrasing.
|
|
202
|
+
|
|
203
|
+
When you detect a pivot, call `search_memory` with a query for the new
|
|
204
|
+
topic. **ADD the results to your working set; do not replace it.** The
|
|
205
|
+
prior topic may come back later in the same session, and the agent should
|
|
206
|
+
not have to re-search for memories it already saw.
|
|
207
|
+
""",
|
|
208
|
+
"jit": """\
|
|
209
|
+
## At session start
|
|
210
|
+
|
|
211
|
+
Call `register_session(api_key="<your-api-key>")` to authenticate. Do NOT
|
|
212
|
+
call `search_memory`. There is no working set in this pattern.
|
|
213
|
+
|
|
214
|
+
## During the session
|
|
215
|
+
|
|
216
|
+
- Call `search_memory` only when you encounter a question whose answer
|
|
217
|
+
might be in memory. Each search is one-shot.
|
|
218
|
+
- Triggers that warrant a search: the user asks "what did we decide
|
|
219
|
+
about X?", references a project-specific term you don't recognize, or
|
|
220
|
+
asks for a recommendation that should reflect prior decisions.
|
|
221
|
+
- After acting on a search result, let it drop from context once the
|
|
222
|
+
immediate question is answered. Do not accumulate a working set.
|
|
223
|
+
|
|
224
|
+
This pattern minimizes startup token cost at the price of missing
|
|
225
|
+
implicit context. Use it for narrow one-shot tooling sessions.
|
|
226
|
+
""",
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
_HYGIENE_BLOCK = """\
|
|
231
|
+
## Memory hygiene
|
|
232
|
+
|
|
233
|
+
- Keep memories concise and self-contained. Another agent should
|
|
234
|
+
understand them without re-loading the conversation that produced them.
|
|
235
|
+
- DO write preferences, decisions, architectural choices, tool
|
|
236
|
+
configuration, and workflow patterns. Skip ephemeral things like "user
|
|
237
|
+
asked me to read a file."
|
|
238
|
+
- Use `update_memory` (not `write_memory`) to revise an existing entry —
|
|
239
|
+
this preserves version history. Use `write_memory` only for new facts.
|
|
240
|
+
- Set weights deliberately: `1.0` for critical policies, `0.8-0.9` for
|
|
241
|
+
strong preferences, `0.5-0.7` for nice-to-know context.
|
|
242
|
+
- Add rationale branches via `parent_id` + `branch_type="rationale"` when
|
|
243
|
+
the "why" behind a preference is load-bearing.
|
|
244
|
+
- Use the right scope: `user` for personal preferences, `project` for
|
|
245
|
+
project-specific context, `organizational` for team/org patterns,
|
|
246
|
+
`enterprise` for mandated policies.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
_CONTRADICTION_ENABLED = """\
|
|
251
|
+
## Contradiction handling
|
|
252
|
+
|
|
253
|
+
When you notice the user's behavior contradicting a memory you have
|
|
254
|
+
loaded, call `report_contradiction` with the memory_id and a one-sentence
|
|
255
|
+
description of the observed behavior. The server tracks contradiction
|
|
256
|
+
counts and surfaces stale memories for review.
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
_CONTRADICTION_DISABLED = """\
|
|
261
|
+
## Contradiction handling
|
|
262
|
+
|
|
263
|
+
This project runs with `cross_domain_contradiction_detection: false` in
|
|
264
|
+
`.memoryhub.yaml`, which means you only catch contradictions for memories
|
|
265
|
+
inside your current working set. Memories from other domains will not be
|
|
266
|
+
checked — that's a deliberate tradeoff for token efficiency over coverage.
|
|
267
|
+
|
|
268
|
+
When you DO notice a contradiction with a loaded memory, call
|
|
269
|
+
`report_contradiction` with the memory_id and a one-sentence description.
|
|
270
|
+
"""
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
_PATTERN_TITLES: dict[LoadingPattern, str] = {
|
|
274
|
+
"eager": "Eager",
|
|
275
|
+
"lazy": "Lazy",
|
|
276
|
+
"lazy_with_rebias": "Lazy + Rebias on Pivot",
|
|
277
|
+
"jit": "Just-in-Time",
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def render_rule_file(config: ProjectConfig) -> str:
|
|
282
|
+
"""Render the full `.claude/rules/memoryhub-loading.md` for a config."""
|
|
283
|
+
pattern = config.memory_loading.pattern
|
|
284
|
+
pattern_block = _PATTERN_BLOCKS[pattern]
|
|
285
|
+
contradiction_block = (
|
|
286
|
+
_CONTRADICTION_ENABLED
|
|
287
|
+
if config.memory_loading.cross_domain_contradiction_detection
|
|
288
|
+
else _CONTRADICTION_DISABLED
|
|
289
|
+
)
|
|
290
|
+
return "\n".join(
|
|
291
|
+
[
|
|
292
|
+
_RULE_HEADER.format(pattern_title=_PATTERN_TITLES[pattern]),
|
|
293
|
+
pattern_block,
|
|
294
|
+
_HYGIENE_BLOCK,
|
|
295
|
+
contradiction_block,
|
|
296
|
+
]
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ── Filesystem helpers ───────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@dataclass
|
|
304
|
+
class WriteResult:
|
|
305
|
+
"""Outcome of writing the project config + rule file to disk."""
|
|
306
|
+
|
|
307
|
+
yaml_path: Path
|
|
308
|
+
rule_path: Path
|
|
309
|
+
legacy_backup: Path | None # set when an old memoryhub-integration.md was moved
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
LEGACY_RULE_NAME = "memoryhub-integration.md"
|
|
313
|
+
GENERATED_RULE_NAME = "memoryhub-loading.md"
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _backup_legacy_rule(rules_dir: Path) -> Path | None:
|
|
317
|
+
"""Move any existing memoryhub-integration.md aside to a .bak file.
|
|
318
|
+
|
|
319
|
+
Returns the backup path, or None if no legacy file existed. Preserves
|
|
320
|
+
multiple backups by appending a numeric suffix when a .bak already
|
|
321
|
+
exists from an earlier run.
|
|
322
|
+
"""
|
|
323
|
+
legacy = rules_dir / LEGACY_RULE_NAME
|
|
324
|
+
if not legacy.exists():
|
|
325
|
+
return None
|
|
326
|
+
backup = legacy.with_suffix(legacy.suffix + ".bak")
|
|
327
|
+
n = 1
|
|
328
|
+
while backup.exists():
|
|
329
|
+
backup = legacy.with_suffix(f"{legacy.suffix}.bak.{n}")
|
|
330
|
+
n += 1
|
|
331
|
+
legacy.rename(backup)
|
|
332
|
+
return backup
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def write_init_files(
|
|
336
|
+
config: ProjectConfig,
|
|
337
|
+
project_dir: Path,
|
|
338
|
+
*,
|
|
339
|
+
overwrite: bool = False,
|
|
340
|
+
) -> WriteResult:
|
|
341
|
+
"""Write both `.memoryhub.yaml` and the generated rule file.
|
|
342
|
+
|
|
343
|
+
Used by `memoryhub config init`. The regenerate flow uses
|
|
344
|
+
:func:`rewrite_rule_file` instead so it does not clobber the
|
|
345
|
+
user-edited YAML.
|
|
346
|
+
|
|
347
|
+
Raises:
|
|
348
|
+
FileExistsError: If either generated file already exists and
|
|
349
|
+
``overwrite`` is False. The legacy rule file is always
|
|
350
|
+
backed up (never silently overwritten).
|
|
351
|
+
"""
|
|
352
|
+
project_dir = Path(project_dir)
|
|
353
|
+
yaml_path = project_dir / CONFIG_FILENAME
|
|
354
|
+
rules_dir = project_dir / ".claude" / "rules"
|
|
355
|
+
rule_path = rules_dir / GENERATED_RULE_NAME
|
|
356
|
+
|
|
357
|
+
if not overwrite:
|
|
358
|
+
for existing in (yaml_path, rule_path):
|
|
359
|
+
if existing.exists():
|
|
360
|
+
raise FileExistsError(
|
|
361
|
+
f"{existing} already exists. Re-run with --force to overwrite."
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
rules_dir.mkdir(parents=True, exist_ok=True)
|
|
365
|
+
legacy_backup = _backup_legacy_rule(rules_dir)
|
|
366
|
+
|
|
367
|
+
yaml_path.write_text(render_yaml(config))
|
|
368
|
+
rule_path.write_text(render_rule_file(config))
|
|
369
|
+
|
|
370
|
+
return WriteResult(
|
|
371
|
+
yaml_path=yaml_path,
|
|
372
|
+
rule_path=rule_path,
|
|
373
|
+
legacy_backup=legacy_backup,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def rewrite_rule_file(
|
|
378
|
+
config: ProjectConfig,
|
|
379
|
+
project_dir: Path,
|
|
380
|
+
) -> WriteResult:
|
|
381
|
+
"""Rewrite only the rule file from a config; leave .memoryhub.yaml alone.
|
|
382
|
+
|
|
383
|
+
Used by `memoryhub config regenerate` after the user edits the YAML
|
|
384
|
+
by hand. The legacy rule file is still backed up if present.
|
|
385
|
+
"""
|
|
386
|
+
project_dir = Path(project_dir)
|
|
387
|
+
yaml_path = project_dir / CONFIG_FILENAME
|
|
388
|
+
rules_dir = project_dir / ".claude" / "rules"
|
|
389
|
+
rule_path = rules_dir / GENERATED_RULE_NAME
|
|
390
|
+
|
|
391
|
+
rules_dir.mkdir(parents=True, exist_ok=True)
|
|
392
|
+
legacy_backup = _backup_legacy_rule(rules_dir)
|
|
393
|
+
|
|
394
|
+
rule_path.write_text(render_rule_file(config))
|
|
395
|
+
|
|
396
|
+
return WriteResult(
|
|
397
|
+
yaml_path=yaml_path,
|
|
398
|
+
rule_path=rule_path,
|
|
399
|
+
legacy_backup=legacy_backup,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
__all__ = [
|
|
404
|
+
"InitChoices",
|
|
405
|
+
"SessionShape",
|
|
406
|
+
"LoadingPattern",
|
|
407
|
+
"FocusSource",
|
|
408
|
+
"WriteResult",
|
|
409
|
+
"GENERATED_RULE_NAME",
|
|
410
|
+
"LEGACY_RULE_NAME",
|
|
411
|
+
"build_project_config",
|
|
412
|
+
"render_yaml",
|
|
413
|
+
"render_rule_file",
|
|
414
|
+
"rewrite_rule_file",
|
|
415
|
+
"suggest_pattern",
|
|
416
|
+
"write_init_files",
|
|
417
|
+
]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: memoryhub-cli
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: CLI client for MemoryHub — centralized, governed memory for AI agents
|
|
5
|
+
Project-URL: Homepage, https://github.com/redhat-ai-americas/memory-hub
|
|
6
|
+
Project-URL: Repository, https://github.com/redhat-ai-americas/memory-hub
|
|
7
|
+
Project-URL: Issues, https://github.com/redhat-ai-americas/memory-hub/issues
|
|
8
|
+
Author: Wes Jackson
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Keywords: agents,ai,cli,mcp,memory
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: memoryhub>=0.1.0
|
|
22
|
+
Requires-Dist: pyyaml>=6.0
|
|
23
|
+
Requires-Dist: rich>=13.0
|
|
24
|
+
Requires-Dist: typer[all]>=0.15
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# memoryhub-cli
|
|
31
|
+
|
|
32
|
+
Command-line client for MemoryHub — centralized, governed memory for AI agents.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install memoryhub-cli
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# Authenticate to a MemoryHub instance
|
|
44
|
+
memoryhub login
|
|
45
|
+
|
|
46
|
+
# Search for memories
|
|
47
|
+
memoryhub search "deployment patterns"
|
|
48
|
+
|
|
49
|
+
# Read a specific memory
|
|
50
|
+
memoryhub read <memory-id>
|
|
51
|
+
|
|
52
|
+
# Write a new memory
|
|
53
|
+
memoryhub write "Use Podman, not Docker" --scope user --weight 0.9
|
|
54
|
+
|
|
55
|
+
# Set up project-level memory loading
|
|
56
|
+
memoryhub config init
|
|
57
|
+
memoryhub config regenerate
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Project configuration
|
|
61
|
+
|
|
62
|
+
`memoryhub config` generates a project-local `.memoryhub.yaml` and a companion `.claude/rules/memoryhub-loading.md` rule file. Both files are meant to be committed so every contributor's agent inherits the same loading policy.
|
|
63
|
+
|
|
64
|
+
`memoryhub config init` is an interactive wizard that asks about session shape, loading pattern, focus source, and retrieval defaults, then writes both files at the project root. If a legacy `.claude/rules/memoryhub-integration.md` already exists, it is backed up to `.bak` before the new rule file is written.
|
|
65
|
+
|
|
66
|
+
`memoryhub config regenerate` re-renders the rule file from `.memoryhub.yaml` after you hand-edit the YAML. It reads the YAML and rewrites the Markdown rule file only; it does not modify `.memoryhub.yaml`.
|
|
67
|
+
|
|
68
|
+
Per-developer connection params (`url`, `auth_url`, `client_id`, `client_secret`) live separately at `~/.config/memoryhub/config.json` and are managed by `memoryhub login`. They are not stored in `.memoryhub.yaml` and are not committed.
|
|
69
|
+
|
|
70
|
+
## Further documentation
|
|
71
|
+
|
|
72
|
+
The CLI is one surface of the [memory-hub](https://github.com/redhat-ai-americas/memory-hub) monorepo. For deeper context:
|
|
73
|
+
|
|
74
|
+
- **[Architecture overview](https://github.com/redhat-ai-americas/memory-hub/blob/main/docs/ARCHITECTURE.md)** — System design, deployment topology
|
|
75
|
+
- **[MCP server tool reference](https://github.com/redhat-ai-americas/memory-hub/blob/main/docs/mcp-server.md)** — The 15 tools the CLI wraps
|
|
76
|
+
- **[Agent memory ergonomics design](https://github.com/redhat-ai-americas/memory-hub/blob/main/docs/agent-memory-ergonomics/design.md)** — Full `.memoryhub.yaml` schema, rule file templates, and session-loading patterns
|
|
77
|
+
- **[Python SDK](https://pypi.org/project/memoryhub/)** — if you'd rather call the tools from Python
|
|
78
|
+
|
|
79
|
+
## Links
|
|
80
|
+
|
|
81
|
+
- **[GitHub repository](https://github.com/redhat-ai-americas/memory-hub)**
|
|
82
|
+
- **[Issue tracker](https://github.com/redhat-ai-americas/memory-hub/issues)**
|
|
83
|
+
- **[License (Apache 2.0)](https://github.com/redhat-ai-americas/memory-hub/blob/main/LICENSE)**
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
memoryhub_cli/__init__.py,sha256=qpv8--Pq9bszQpeJomDelIVQkg-cHIabTJ4fPq7m_O8,51
|
|
2
|
+
memoryhub_cli/config.py,sha256=fvAJilz5OaknpbPQ8ZXCGTF96XzbwxTMNCmT4QlwD7Y,1342
|
|
3
|
+
memoryhub_cli/main.py,sha256=ZZn2bVC0qODznmIimbg7PBoSilbO9YAKHw1K-VaLsqs,15292
|
|
4
|
+
memoryhub_cli/project_config.py,sha256=QAgiL29Xb78ahak7YdszAu3Br9B0SHoOrWgSzcROnDE,14239
|
|
5
|
+
memoryhub_cli-0.1.1.dist-info/METADATA,sha256=cs9Fl_zll4QUvXmp9PjU25t4i3PY2r-fm1et72xbyTM,3737
|
|
6
|
+
memoryhub_cli-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
7
|
+
memoryhub_cli-0.1.1.dist-info/entry_points.txt,sha256=eXwBBQKrI_bBPPBxLrwGIlJr7wb3lTO9IrtmtA1c_ns,53
|
|
8
|
+
memoryhub_cli-0.1.1.dist-info/RECORD,,
|