ravmem-cli 0.1.0__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.
ravmem/cli/kg.py ADDED
@@ -0,0 +1,787 @@
1
+ """ravmem kg — knowledge graph management subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as _json
6
+ import sys
7
+ from typing import Optional
8
+
9
+ import typer
10
+ from rich import box
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.table import Table
14
+
15
+ app = typer.Typer(
16
+ name="kg",
17
+ help="Knowledge graph management — browse, curate, and query agent memory.",
18
+ no_args_is_help=True,
19
+ )
20
+
21
+ _console = Console()
22
+ _err = Console(stderr=True)
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Shared helpers
26
+ # ---------------------------------------------------------------------------
27
+
28
+ def _load_server_cfg(remote: str) -> tuple[str, str | None]:
29
+ """Return (server_url, api_key) from --remote override or ~/.ravmem/client.json."""
30
+ if remote:
31
+ return remote.rstrip("/"), None
32
+ import os
33
+ cfg_path = os.path.expanduser("~/.ravmem/client.json")
34
+ if not os.path.exists(cfg_path):
35
+ _err.print("[red]No server configured.[/red] Run: ravmem remote config --url <URL>")
36
+ raise typer.Exit(1)
37
+ with open(cfg_path) as f:
38
+ data = _json.load(f)
39
+ url = data.get("server_url", "").rstrip("/")
40
+ if not url:
41
+ _err.print("[red]server_url missing in client.json.[/red] Run: ravmem remote config --url <URL>")
42
+ raise typer.Exit(1)
43
+ return url, data.get("api_key") or None
44
+
45
+
46
+ def _kg_client(remote: str, team: str, branch: str):
47
+ """Return an httpx.Client with KG session headers pre-set."""
48
+ import httpx
49
+ url, api_key = _load_server_cfg(remote)
50
+ headers = {
51
+ "X-KG-Team": team,
52
+ "X-KG-Branch": branch,
53
+ "Content-Type": "application/json",
54
+ }
55
+ if api_key:
56
+ headers["Authorization"] = f"Bearer {api_key}"
57
+ return url, httpx.Client(base_url=url, headers=headers, timeout=15.0)
58
+
59
+
60
+ def _kg_get(remote: str, team: str, branch: str, path: str, params: dict | None = None) -> dict | list:
61
+ url, client = _kg_client(remote, team, branch)
62
+ with client:
63
+ resp = client.get(path, params=params or {})
64
+ if resp.status_code == 404:
65
+ _err.print(f"[yellow]Not found:[/yellow] {path}")
66
+ raise typer.Exit(1)
67
+ resp.raise_for_status()
68
+ return resp.json()
69
+
70
+
71
+ def _kg_post(remote: str, team: str, branch: str, path: str, body: dict) -> dict:
72
+ url, client = _kg_client(remote, team, branch)
73
+ with client:
74
+ resp = client.post(path, json=body)
75
+ resp.raise_for_status()
76
+ return resp.json()
77
+
78
+
79
+ def _kg_patch(remote: str, team: str, branch: str, path: str, body: dict) -> dict:
80
+ url, client = _kg_client(remote, team, branch)
81
+ with client:
82
+ resp = client.patch(path, json=body)
83
+ resp.raise_for_status()
84
+ return resp.json()
85
+
86
+
87
+ def _kg_delete(remote: str, team: str, branch: str, path: str) -> dict:
88
+ url, client = _kg_client(remote, team, branch)
89
+ with client:
90
+ resp = client.delete(path)
91
+ resp.raise_for_status()
92
+ return resp.json()
93
+
94
+
95
+ def _trunc(s: str, n: int) -> str:
96
+ s = str(s or "")
97
+ return s if len(s) <= n else s[: n - 1] + "…"
98
+
99
+
100
+ def _print_node_table(nodes: list[dict], title: str = "") -> None:
101
+ t = Table(box=box.SIMPLE, title=title or None, show_header=True, header_style="bold")
102
+ t.add_column("ID", style="dim", width=10)
103
+ t.add_column("Type", width=12)
104
+ t.add_column("Label", width=55)
105
+ t.add_column("Conf", width=5, justify="right")
106
+ t.add_column("Source", width=8)
107
+ t.add_column("Created", width=19)
108
+ for n in nodes:
109
+ conf = n.get("confidence")
110
+ conf_str = f"{conf:.2f}" if conf is not None else "—"
111
+ t.add_row(
112
+ n.get("id", "")[:8],
113
+ n.get("type", ""),
114
+ _trunc(n.get("label", ""), 55),
115
+ conf_str,
116
+ n.get("source", ""),
117
+ str(n.get("created_at", ""))[:19],
118
+ )
119
+ _console.print(t)
120
+
121
+
122
+ def _print_node_detail(node: dict) -> None:
123
+ lines = []
124
+ for k in ("id", "type", "label", "scope", "branch", "team_id", "source",
125
+ "author_id", "active", "confidence", "validated_by_human",
126
+ "origin_node_id", "created_at", "updated_at"):
127
+ v = node.get(k)
128
+ if v is not None:
129
+ lines.append(f"[bold]{k}[/bold]: {v}")
130
+ # Extra properties
131
+ skip = {"id", "type", "label", "scope", "branch", "team_id", "source",
132
+ "author_id", "active", "confidence", "validated_by_human",
133
+ "origin_node_id", "created_at", "updated_at"}
134
+ for k, v in node.items():
135
+ if k not in skip and v is not None:
136
+ lines.append(f"[bold]{k}[/bold]: {v}")
137
+ _console.print(Panel("\n".join(lines), title=f"[cyan]{node.get('type', 'Node')}[/cyan] {node.get('id', '')[:8]}"))
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Existing commands (init, config) — unchanged
142
+ # ---------------------------------------------------------------------------
143
+
144
+ @app.command("init")
145
+ def init_cmd(
146
+ team: str = typer.Option("default", "--team", "-t", help="Team identifier for this KGConfig"),
147
+ branch: str = typer.Option("global", "--branch", "-b", help="Knowledge branch to configure"),
148
+ remote: str = typer.Option("", "--remote", "-r", help="Server URL (overrides client.json)"),
149
+ ):
150
+ """Interactive wizard to initialise or reconfigure the knowledge graph."""
151
+ from ravmem.knowledge.init import run_init
152
+
153
+ existing_cfg = None
154
+ server_url = remote
155
+
156
+ if not server_url:
157
+ try:
158
+ server_url, _ = _load_server_cfg("")
159
+ except typer.Exit:
160
+ server_url = ""
161
+
162
+ if server_url:
163
+ try:
164
+ import httpx
165
+ from ravmem.knowledge.config import KGConfig
166
+ resp = httpx.get(
167
+ f"{server_url}/api/knowledge/config",
168
+ params={"team_id": team, "branch": branch},
169
+ timeout=5,
170
+ )
171
+ if resp.status_code == 200:
172
+ data = resp.json()
173
+ existing_cfg = KGConfig(
174
+ team_id=data.get("team_id", team),
175
+ branch=data.get("branch", branch),
176
+ mode=data.get("mode", "hitl"),
177
+ confidence_enabled=data.get("confidence_enabled", True),
178
+ hitl_ai_cap=data.get("hitl_ai_cap", 0.70),
179
+ auto_ai_cap=data.get("auto_ai_cap", 1.0),
180
+ disabled_node_types=data.get("disabled_node_types", []),
181
+ )
182
+ except Exception:
183
+ pass
184
+
185
+ def _save(cfg):
186
+ if not server_url:
187
+ typer.echo("Warning: no server configured — config not persisted.", err=True)
188
+ return
189
+ try:
190
+ import httpx
191
+ resp = httpx.post(f"{server_url}/api/knowledge/config", json=cfg.to_dict(), timeout=5)
192
+ resp.raise_for_status()
193
+ except Exception as exc:
194
+ typer.echo(f"Failed to save config: {exc}", err=True)
195
+ raise typer.Exit(1)
196
+
197
+ run_init(team_id=team, branch=branch, existing_cfg=existing_cfg,
198
+ save_fn=_save if server_url else None)
199
+
200
+
201
+ @app.command("config")
202
+ def config_cmd(
203
+ team: str = typer.Option("default", "--team", "-t"),
204
+ branch: str = typer.Option("global", "--branch", "-b"),
205
+ remote: str = typer.Option("", "--remote", "-r"),
206
+ ):
207
+ """Show current knowledge graph configuration from the server."""
208
+ try:
209
+ data = _kg_get(remote, team, branch, "/api/knowledge/config",
210
+ params={"team_id": team, "branch": branch})
211
+ except typer.Exit:
212
+ typer.echo("No config found. Run: ravmem kg init")
213
+ return
214
+
215
+ t = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))
216
+ t.add_column("Setting", style="bold")
217
+ t.add_column("Value")
218
+ t.add_row("Team", data.get("team_id", team))
219
+ t.add_row("Branch", data.get("branch", branch))
220
+ t.add_row("Mode", data.get("mode", "?").upper())
221
+ t.add_row("Confidence scoring", "enabled" if data.get("confidence_enabled", True) else "disabled")
222
+ t.add_row("HITL AI cap", str(data.get("hitl_ai_cap", "?")))
223
+ t.add_row("Auto AI cap", str(data.get("auto_ai_cap", "?")))
224
+ disabled = data.get("disabled_node_types", [])
225
+ if disabled:
226
+ t.add_row("Disabled node types", ", ".join(disabled))
227
+ _console.print(t)
228
+
229
+
230
+ # ---------------------------------------------------------------------------
231
+ # Phase 2 — Browse and inspect
232
+ # ---------------------------------------------------------------------------
233
+
234
+ @app.command("list")
235
+ def list_cmd(
236
+ node_type: Optional[str] = typer.Option(None, "--type", "-T",
237
+ help="Filter by type: instruction|condition|learning|decision|task|blocker|context|taskpattern"),
238
+ scope: Optional[str] = typer.Option(None, "--scope", "-s",
239
+ help="Filter by scope: global | project:<id> | task_type:<pattern>"),
240
+ all_nodes: bool = typer.Option(False, "--all", "-a",
241
+ help="Include inactive nodes (default: active only)"),
242
+ limit: int = typer.Option(50, "--limit", "-n"),
243
+ output_json: bool = typer.Option(False, "--json", help="Raw JSON output"),
244
+ team: str = typer.Option("default", "--team", "-t"),
245
+ branch: str = typer.Option("global", "--branch", "-b"),
246
+ remote: str = typer.Option("", "--remote", "-r"),
247
+ ):
248
+ """List knowledge graph nodes."""
249
+ params: dict = {"limit": limit}
250
+ if node_type:
251
+ params["node_type"] = node_type
252
+ if scope:
253
+ params["scope"] = scope
254
+ if all_nodes:
255
+ params["active_only"] = "false"
256
+
257
+ data = _kg_get(remote, team, branch, "/api/knowledge/nodes", params=params)
258
+ nodes = data if isinstance(data, list) else data.get("nodes", [])
259
+
260
+ if output_json:
261
+ typer.echo(_json.dumps(nodes, indent=2, default=str))
262
+ return
263
+
264
+ if not nodes:
265
+ typer.echo("No nodes found.")
266
+ return
267
+
268
+ label = f"{len(nodes)} node(s)"
269
+ if node_type:
270
+ label += f" — type={node_type}"
271
+ _print_node_table(nodes, title=label)
272
+
273
+
274
+ @app.command("get")
275
+ def get_cmd(
276
+ node_id: str = typer.Argument(..., help="Node ID (full or 8-char prefix)"),
277
+ output_json: bool = typer.Option(False, "--json"),
278
+ team: str = typer.Option("default", "--team", "-t"),
279
+ branch: str = typer.Option("global", "--branch", "-b"),
280
+ remote: str = typer.Option("", "--remote", "-r"),
281
+ ):
282
+ """Show full details for a single knowledge node."""
283
+ node = _kg_get(remote, team, branch, f"/api/knowledge/nodes/{node_id}")
284
+ if output_json:
285
+ typer.echo(_json.dumps(node, indent=2, default=str))
286
+ return
287
+ _print_node_detail(node)
288
+
289
+
290
+ @app.command("search")
291
+ def search_cmd(
292
+ query: str = typer.Argument(..., help="Search query"),
293
+ limit: int = typer.Option(10, "--limit", "-n"),
294
+ output_json: bool = typer.Option(False, "--json"),
295
+ team: str = typer.Option("default", "--team", "-t"),
296
+ branch: str = typer.Option("global", "--branch", "-b"),
297
+ remote: str = typer.Option("", "--remote", "-r"),
298
+ ):
299
+ """Search knowledge nodes by keyword."""
300
+ data = _kg_get(remote, team, branch, "/api/knowledge/search",
301
+ params={"q": query, "limit": limit,
302
+ "team_id": team, "branch": branch})
303
+ nodes = data if isinstance(data, list) else data.get("results", [])
304
+
305
+ if output_json:
306
+ typer.echo(_json.dumps(nodes, indent=2, default=str))
307
+ return
308
+
309
+ if not nodes:
310
+ typer.echo(f"No results for '{query}'.")
311
+ return
312
+
313
+ _print_node_table(nodes, title=f"Results for '{query}'")
314
+
315
+
316
+ @app.command("delete")
317
+ def delete_cmd(
318
+ node_id: str = typer.Argument(..., help="Node ID to soft-delete (deactivate)"),
319
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
320
+ team: str = typer.Option("default", "--team", "-t"),
321
+ branch: str = typer.Option("global", "--branch", "-b"),
322
+ remote: str = typer.Option("", "--remote", "-r"),
323
+ ):
324
+ """Soft-delete (deactivate) a knowledge node."""
325
+ if not yes:
326
+ typer.confirm(f"Deactivate node {node_id!r}?", abort=True)
327
+ _kg_delete(remote, team, branch, f"/api/knowledge/nodes/{node_id}")
328
+ typer.echo(f"Node {node_id} deactivated.")
329
+
330
+
331
+ # ---------------------------------------------------------------------------
332
+ # Phase 1 — Human workflow: instructions, conditions, learnings
333
+ # ---------------------------------------------------------------------------
334
+
335
+ @app.command("set-instruction")
336
+ def set_instruction_cmd(
337
+ text: str = typer.Argument(..., help="Instruction text"),
338
+ priority: str = typer.Option("medium", "--priority", "-p",
339
+ help="Priority: critical | high | medium | low"),
340
+ scope: str = typer.Option("global", "--scope", "-s",
341
+ help="Scope: global | project:<id> | task_type:<pattern>"),
342
+ output_json: bool = typer.Option(False, "--json"),
343
+ team: str = typer.Option("default", "--team", "-t"),
344
+ branch: str = typer.Option("global", "--branch", "-b"),
345
+ remote: str = typer.Option("", "--remote", "-r"),
346
+ ):
347
+ """Create a human-authored instruction rule."""
348
+ body = {
349
+ "label": text,
350
+ "priority": priority,
351
+ "scope": scope,
352
+ "team_id": team,
353
+ "branch": branch,
354
+ }
355
+ result = _kg_post(remote, team, branch, "/api/knowledge/instructions", body)
356
+ if output_json:
357
+ typer.echo(_json.dumps(result, indent=2, default=str))
358
+ return
359
+ node_id = result.get("id", result.get("node_id", "?"))
360
+ typer.echo(f"Instruction created: {node_id}")
361
+ typer.echo(f" Priority : {priority}")
362
+ typer.echo(f" Scope : {scope}")
363
+ typer.echo(f" Text : {text}")
364
+
365
+
366
+ @app.command("set-condition")
367
+ def set_condition_cmd(
368
+ when: str = typer.Option(..., "--when", "-w", help="Trigger condition (if clause)"),
369
+ then: str = typer.Option(..., "--then", "-t", help="Action to take (then clause)"),
370
+ scope: str = typer.Option("global", "--scope", "-s"),
371
+ output_json: bool = typer.Option(False, "--json"),
372
+ team: str = typer.Option("default", "--team"),
373
+ branch: str = typer.Option("global", "--branch", "-b"),
374
+ remote: str = typer.Option("", "--remote", "-r"),
375
+ ):
376
+ """Create an if/then condition rule."""
377
+ body = {
378
+ "label": f"IF {when} THEN {then}",
379
+ "when_clause": when,
380
+ "then_clause": then,
381
+ "scope": scope,
382
+ "team_id": team,
383
+ "branch": branch,
384
+ }
385
+ result = _kg_post(remote, team, branch, "/api/knowledge/conditions", body)
386
+ if output_json:
387
+ typer.echo(_json.dumps(result, indent=2, default=str))
388
+ return
389
+ node_id = result.get("id", result.get("node_id", "?"))
390
+ typer.echo(f"Condition created: {node_id}")
391
+ typer.echo(f" When : {when}")
392
+ typer.echo(f" Then : {then}")
393
+
394
+
395
+ @app.command("validate-learning")
396
+ def validate_learning_cmd(
397
+ learning_id: str = typer.Argument(..., help="Learning node ID"),
398
+ accept: bool = typer.Option(False, "--accept", "-a", help="Accept the learning"),
399
+ reject: bool = typer.Option(False, "--reject", "-r", help="Reject the learning"),
400
+ confidence: Optional[float] = typer.Option(None, "--confidence", "-c",
401
+ help="Override confidence (0.0–1.0, accept only)"),
402
+ output_json: bool = typer.Option(False, "--json"),
403
+ team: str = typer.Option("default", "--team", "-t"),
404
+ branch: str = typer.Option("global", "--branch", "-b"),
405
+ remote: str = typer.Option("", "--remote", "-r"),
406
+ ):
407
+ """Accept or reject an AI-generated learning."""
408
+ if not accept and not reject:
409
+ _err.print("[red]Specify --accept or --reject.[/red]")
410
+ raise typer.Exit(1)
411
+ if accept and reject:
412
+ _err.print("[red]Specify only one of --accept or --reject.[/red]")
413
+ raise typer.Exit(1)
414
+
415
+ body: dict = {"accepted": accept}
416
+ if confidence is not None and accept:
417
+ body["confidence"] = confidence
418
+
419
+ result = _kg_post(remote, team, branch,
420
+ f"/api/knowledge/learnings/{learning_id}/validate", body)
421
+ if output_json:
422
+ typer.echo(_json.dumps(result, indent=2, default=str))
423
+ return
424
+ verdict = "accepted" if accept else "rejected"
425
+ typer.echo(f"Learning {learning_id} {verdict}.")
426
+ if accept and confidence is not None:
427
+ typer.echo(f" Confidence set to {confidence}")
428
+
429
+
430
+ @app.command("override-instruction")
431
+ def override_instruction_cmd(
432
+ instruction_id: str = typer.Argument(..., help="ID of the instruction to replace"),
433
+ text: str = typer.Argument(..., help="New instruction text"),
434
+ priority: str = typer.Option("medium", "--priority", "-p"),
435
+ output_json: bool = typer.Option(False, "--json"),
436
+ team: str = typer.Option("default", "--team", "-t"),
437
+ branch: str = typer.Option("global", "--branch", "-b"),
438
+ remote: str = typer.Option("", "--remote", "-r"),
439
+ ):
440
+ """Replace an instruction (deactivates the original, creates a new one)."""
441
+ body = {
442
+ "label": text,
443
+ "priority": priority,
444
+ "team_id": team,
445
+ "branch": branch,
446
+ }
447
+ result = _kg_post(remote, team, branch,
448
+ f"/api/knowledge/instructions/{instruction_id}/override", body)
449
+ if output_json:
450
+ typer.echo(_json.dumps(result, indent=2, default=str))
451
+ return
452
+ new_id = result.get("id", result.get("node_id", "?"))
453
+ typer.echo(f"Instruction {instruction_id} overridden → new ID: {new_id}")
454
+ typer.echo(f" Text : {text}")
455
+ typer.echo(f" Priority : {priority}")
456
+
457
+
458
+ @app.command("promote")
459
+ def promote_cmd(
460
+ learning_id: str = typer.Argument(..., help="Learning node ID to promote to Instruction"),
461
+ output_json: bool = typer.Option(False, "--json"),
462
+ team: str = typer.Option("default", "--team", "-t"),
463
+ branch: str = typer.Option("global", "--branch", "-b"),
464
+ remote: str = typer.Option("", "--remote", "-r"),
465
+ ):
466
+ """Promote a validated learning to an instruction."""
467
+ result = _kg_post(remote, team, branch,
468
+ f"/api/knowledge/learnings/{learning_id}/promote", {})
469
+ if output_json:
470
+ typer.echo(_json.dumps(result, indent=2, default=str))
471
+ return
472
+ new_id = result.get("id", result.get("node_id", "?"))
473
+ typer.echo(f"Learning {learning_id} promoted to Instruction: {new_id}")
474
+
475
+
476
+ # ---------------------------------------------------------------------------
477
+ # Phase 3 — Task lifecycle
478
+ # ---------------------------------------------------------------------------
479
+
480
+ @app.command("start-task")
481
+ def start_task_cmd(
482
+ label: str = typer.Argument(..., help="Task description"),
483
+ task_type: str = typer.Option("general", "--type", "-T", help="Task type / pattern key"),
484
+ scope: str = typer.Option("global", "--scope", "-s"),
485
+ output_json: bool = typer.Option(False, "--json"),
486
+ team: str = typer.Option("default", "--team", "-t"),
487
+ branch: str = typer.Option("global", "--branch", "-b"),
488
+ remote: str = typer.Option("", "--remote", "-r"),
489
+ ):
490
+ """Register a new task and load its context (instructions + conditions)."""
491
+ body = {
492
+ "label": label,
493
+ "task_type": task_type,
494
+ "scope": scope,
495
+ "team_id": team,
496
+ "branch": branch,
497
+ }
498
+ result = _kg_post(remote, team, branch, "/api/knowledge/tasks", body)
499
+ if output_json:
500
+ typer.echo(_json.dumps(result, indent=2, default=str))
501
+ return
502
+ task_id = result.get("id", result.get("task_id", "?"))
503
+ typer.echo(f"Task started: {task_id}")
504
+ typer.echo(f" Label : {label}")
505
+ typer.echo(f" Type : {task_type}")
506
+ typer.echo(f" Scope : {scope}")
507
+ typer.echo(f"\nLoad context with: ravmem kg load-context {task_id}")
508
+
509
+
510
+ @app.command("complete-task")
511
+ def complete_task_cmd(
512
+ task_id: str = typer.Argument(..., help="Task node ID"),
513
+ outcome: str = typer.Option("", "--outcome", "-o", help="Outcome description"),
514
+ output_json: bool = typer.Option(False, "--json"),
515
+ team: str = typer.Option("default", "--team", "-t"),
516
+ branch: str = typer.Option("global", "--branch", "-b"),
517
+ remote: str = typer.Option("", "--remote", "-r"),
518
+ ):
519
+ """Mark a task as complete."""
520
+ body = {"outcome": outcome, "team_id": team, "branch": branch}
521
+ result = _kg_post(remote, team, branch,
522
+ f"/api/knowledge/tasks/{task_id}/complete", body)
523
+ if output_json:
524
+ typer.echo(_json.dumps(result, indent=2, default=str))
525
+ return
526
+ typer.echo(f"Task {task_id} completed.")
527
+ if outcome:
528
+ typer.echo(f" Outcome: {outcome}")
529
+
530
+
531
+ @app.command("load-context")
532
+ def load_context_cmd(
533
+ task_id: str = typer.Argument(..., help="Task node ID"),
534
+ output_json: bool = typer.Option(False, "--json"),
535
+ team: str = typer.Option("default", "--team", "-t"),
536
+ branch: str = typer.Option("global", "--branch", "-b"),
537
+ remote: str = typer.Option("", "--remote", "-r"),
538
+ ):
539
+ """Run 6-step context resolution for a task (instructions, learnings, decisions, similar tasks)."""
540
+ ctx = _kg_get(remote, team, branch,
541
+ f"/api/knowledge/tasks/{task_id}/context",
542
+ params={"team_id": team, "branch": branch})
543
+ if output_json:
544
+ typer.echo(_json.dumps(ctx, indent=2, default=str))
545
+ return
546
+
547
+ def _section(title: str, nodes: list) -> None:
548
+ if not nodes:
549
+ return
550
+ _console.print(f"\n[bold cyan]{title}[/bold cyan] ({len(nodes)})")
551
+ for n in nodes:
552
+ label = n.get("label", n.get("text", ""))
553
+ nid = n.get("id", "")[:8]
554
+ conf = n.get("confidence")
555
+ conf_str = f" conf={conf:.2f}" if conf is not None else ""
556
+ _console.print(f" [dim]{nid}[/dim] {label}{conf_str}")
557
+
558
+ _console.rule(f"[bold]Context for task {task_id[:8]}[/bold]")
559
+ _section("Instructions", ctx.get("instructions", []))
560
+ _section("Conditions", ctx.get("conditions", []))
561
+ _section("Learnings", ctx.get("learnings", []))
562
+ _section("Decisions", ctx.get("decisions", []))
563
+ _section("Context nodes", ctx.get("context_nodes", []))
564
+ similar = ctx.get("similar_tasks", [])
565
+ if similar:
566
+ _console.print(f"\n[bold cyan]Similar past tasks[/bold cyan] ({len(similar)})")
567
+ for t in similar:
568
+ tid = t.get("id", "")[:8]
569
+ _console.print(f" [dim]{tid}[/dim] {t.get('label', '')}")
570
+
571
+
572
+ @app.command("log-learning")
573
+ def log_learning_cmd(
574
+ text: str = typer.Argument(..., help="Learning text"),
575
+ domain: str = typer.Option("", "--domain", "-d", help="Knowledge domain"),
576
+ task_id: Optional[str] = typer.Option(None, "--task", help="Associated task ID"),
577
+ confidence: float = typer.Option(0.6, "--confidence", "-c", help="Initial confidence (0.0–1.0)"),
578
+ scope: str = typer.Option("global", "--scope", "-s"),
579
+ output_json: bool = typer.Option(False, "--json"),
580
+ team: str = typer.Option("default", "--team", "-t"),
581
+ branch: str = typer.Option("global", "--branch", "-b"),
582
+ remote: str = typer.Option("", "--remote", "-r"),
583
+ ):
584
+ """Record a discovered learning."""
585
+ body: dict = {
586
+ "content": text,
587
+ "domain": domain,
588
+ "confidence": confidence,
589
+ "scope": scope,
590
+ "team_id": team,
591
+ "branch": branch,
592
+ "source": "human",
593
+ "agent_id": "human",
594
+ "task_id": task_id or "",
595
+ }
596
+ result = _kg_post(remote, team, branch, "/api/knowledge/learnings", body)
597
+ if output_json:
598
+ typer.echo(_json.dumps(result, indent=2, default=str))
599
+ return
600
+ node_id = result.get("id", result.get("node_id", "?"))
601
+ typer.echo(f"Learning logged: {node_id}")
602
+ typer.echo(f" Text : {text}")
603
+ typer.echo(f" Confidence : {confidence}")
604
+ if domain:
605
+ typer.echo(f" Domain : {domain}")
606
+
607
+
608
+ @app.command("log-decision")
609
+ def log_decision_cmd(
610
+ text: str = typer.Argument(..., help="Decision summary"),
611
+ rationale: str = typer.Option("", "--rationale", "-r", help="Why this decision was made"),
612
+ alternatives: str = typer.Option("", "--alternatives", "-a",
613
+ help="Comma-separated alternatives that were considered"),
614
+ task_id: Optional[str] = typer.Option(None, "--task", help="Associated task ID"),
615
+ scope: str = typer.Option("global", "--scope", "-s"),
616
+ output_json: bool = typer.Option(False, "--json"),
617
+ team: str = typer.Option("default", "--team", "-t"),
618
+ branch: str = typer.Option("global", "--branch", "-b"),
619
+ remote: str = typer.Option("", "--remote", "-r"),
620
+ ):
621
+ """Record an architectural or implementation decision."""
622
+ body: dict = {
623
+ "label": text,
624
+ "rationale": rationale,
625
+ "alternatives": [a.strip() for a in alternatives.split(",") if a.strip()],
626
+ "scope": scope,
627
+ "team_id": team,
628
+ "branch": branch,
629
+ "source": "human",
630
+ }
631
+ if task_id:
632
+ body["task_id"] = task_id
633
+ result = _kg_post(remote, team, branch, "/api/knowledge/decisions", body)
634
+ if output_json:
635
+ typer.echo(_json.dumps(result, indent=2, default=str))
636
+ return
637
+ node_id = result.get("id", result.get("node_id", "?"))
638
+ typer.echo(f"Decision logged: {node_id}")
639
+ typer.echo(f" Decision : {text}")
640
+ if rationale:
641
+ typer.echo(f" Rationale : {rationale}")
642
+ if alternatives:
643
+ typer.echo(f" Alternatives : {alternatives}")
644
+
645
+
646
+ @app.command("mark-blocked")
647
+ def mark_blocked_cmd(
648
+ reason: str = typer.Argument(..., help="Description of the blocker"),
649
+ task_id: Optional[str] = typer.Option(None, "--task", help="Associated task ID"),
650
+ scope: str = typer.Option("global", "--scope", "-s"),
651
+ output_json: bool = typer.Option(False, "--json"),
652
+ team: str = typer.Option("default", "--team", "-t"),
653
+ branch: str = typer.Option("global", "--branch", "-b"),
654
+ remote: str = typer.Option("", "--remote", "-r"),
655
+ ):
656
+ """Create a blocker node linked to a task."""
657
+ body: dict = {
658
+ "label": reason,
659
+ "scope": scope,
660
+ "team_id": team,
661
+ "branch": branch,
662
+ "source": "human",
663
+ }
664
+ if task_id:
665
+ body["task_id"] = task_id
666
+ result = _kg_post(remote, team, branch, "/api/knowledge/blockers", body)
667
+ if output_json:
668
+ typer.echo(_json.dumps(result, indent=2, default=str))
669
+ return
670
+ node_id = result.get("id", result.get("node_id", "?"))
671
+ typer.echo(f"Blocker created: {node_id}")
672
+ typer.echo(f" Reason : {reason}")
673
+ typer.echo(f"\nResolve with: ravmem kg resolve-blocker {node_id}")
674
+
675
+
676
+ @app.command("resolve-blocker")
677
+ def resolve_blocker_cmd(
678
+ blocker_id: str = typer.Argument(..., help="Blocker node ID"),
679
+ decision_id: Optional[str] = typer.Option(None, "--decision", "-d",
680
+ help="Optional Decision node ID that resolved this blocker"),
681
+ output_json: bool = typer.Option(False, "--json"),
682
+ team: str = typer.Option("default", "--team", "-t"),
683
+ branch: str = typer.Option("global", "--branch", "-b"),
684
+ remote: str = typer.Option("", "--remote", "-r"),
685
+ ):
686
+ """Mark a blocker as resolved."""
687
+ body: dict = {"team_id": team, "branch": branch}
688
+ if decision_id:
689
+ body["decision_id"] = decision_id
690
+ result = _kg_post(remote, team, branch,
691
+ f"/api/knowledge/blockers/{blocker_id}/resolve", body)
692
+ if output_json:
693
+ typer.echo(_json.dumps(result, indent=2, default=str))
694
+ return
695
+ typer.echo(f"Blocker {blocker_id} resolved.")
696
+ if decision_id:
697
+ typer.echo(f" Linked decision: {decision_id}")
698
+
699
+
700
+ # ---------------------------------------------------------------------------
701
+ # Phase 4 — Advanced / power-user
702
+ # ---------------------------------------------------------------------------
703
+
704
+ @app.command("update")
705
+ def update_cmd(
706
+ node_id: str = typer.Argument(..., help="Node ID to update"),
707
+ label: Optional[str] = typer.Option(None, "--label", "-l", help="New label/text"),
708
+ priority: Optional[str] = typer.Option(None, "--priority", "-p",
709
+ help="New priority (instruction nodes only)"),
710
+ output_json: bool = typer.Option(False, "--json"),
711
+ team: str = typer.Option("default", "--team", "-t"),
712
+ branch: str = typer.Option("global", "--branch", "-b"),
713
+ remote: str = typer.Option("", "--remote", "-r"),
714
+ ):
715
+ """Update a node's properties (creates a CoW copy on the current branch)."""
716
+ body: dict = {}
717
+ if label:
718
+ body["label"] = label
719
+ if priority:
720
+ body["priority"] = priority
721
+ if not body:
722
+ _err.print("[red]Provide at least --label or --priority.[/red]")
723
+ raise typer.Exit(1)
724
+ result = _kg_patch(remote, team, branch, f"/api/knowledge/nodes/{node_id}", body)
725
+ if output_json:
726
+ typer.echo(_json.dumps(result, indent=2, default=str))
727
+ return
728
+ new_id = result.get("id", node_id)
729
+ typer.echo(f"Node updated → {new_id}")
730
+
731
+
732
+ @app.command("corroborate")
733
+ def corroborate_cmd(
734
+ learning_id: str = typer.Argument(..., help="Learning node ID"),
735
+ output_json: bool = typer.Option(False, "--json"),
736
+ team: str = typer.Option("default", "--team", "-t"),
737
+ branch: str = typer.Option("global", "--branch", "-b"),
738
+ remote: str = typer.Option("", "--remote", "-r"),
739
+ ):
740
+ """Increment evidence count and nudge confidence on a learning."""
741
+ result = _kg_post(remote, team, branch,
742
+ f"/api/knowledge/learnings/{learning_id}/corroborate", {})
743
+ if output_json:
744
+ typer.echo(_json.dumps(result, indent=2, default=str))
745
+ return
746
+ conf = result.get("confidence")
747
+ ev = result.get("evidence_count")
748
+ typer.echo(f"Learning {learning_id} corroborated.")
749
+ if conf is not None:
750
+ typer.echo(f" Confidence : {conf:.2f}")
751
+ if ev is not None:
752
+ typer.echo(f" Evidence count: {ev}")
753
+
754
+
755
+ @app.command("link-symbols")
756
+ def link_symbols_cmd(
757
+ node_id: str = typer.Argument(..., help="KG node ID"),
758
+ symbol_ids: list[str] = typer.Argument(..., help="One or more code graph Symbol IDs"),
759
+ output_json: bool = typer.Option(False, "--json"),
760
+ team: str = typer.Option("default", "--team", "-t"),
761
+ branch: str = typer.Option("global", "--branch", "-b"),
762
+ remote: str = typer.Option("", "--remote", "-r"),
763
+ ):
764
+ """Link a knowledge node to code graph symbols."""
765
+ body = {"symbol_ids": symbol_ids}
766
+ result = _kg_post(remote, team, branch,
767
+ f"/api/knowledge/nodes/{node_id}/link-symbols", body)
768
+ if output_json:
769
+ typer.echo(_json.dumps(result, indent=2, default=str))
770
+ return
771
+ typer.echo(f"Node {node_id} linked to {len(symbol_ids)} symbol(s).")
772
+
773
+
774
+ @app.command("set-mode")
775
+ def set_mode_cmd(
776
+ mode: str = typer.Argument(..., help="Mode: hitl | auto"),
777
+ team: str = typer.Option("default", "--team", "-t"),
778
+ branch: str = typer.Option("global", "--branch", "-b"),
779
+ remote: str = typer.Option("", "--remote", "-r"),
780
+ ):
781
+ """Switch the knowledge graph between HITL and auto confidence modes."""
782
+ if mode not in ("hitl", "auto"):
783
+ _err.print("[red]Mode must be 'hitl' or 'auto'.[/red]")
784
+ raise typer.Exit(1)
785
+ body = {"mode": mode, "team_id": team, "branch": branch}
786
+ _kg_post(remote, team, branch, "/api/knowledge/config", body)
787
+ typer.echo(f"KG mode set to: {mode.upper()}")