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.
@@ -0,0 +1,3 @@
1
+ """MemoryHub CLI client."""
2
+
3
+ __version__ = "0.1.1"
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ memoryhub = memoryhub_cli.main:app