|
4 | 4 |
|
5 | 5 | import importlib.metadata |
6 | 6 | import json |
| 7 | +import os |
7 | 8 | import re |
8 | 9 | import shutil |
9 | 10 | import subprocess |
|
38 | 39 | list_memory_files, |
39 | 40 | migrate_memory, |
40 | 41 | remove_memory_entry, |
| 42 | + scan_memory_files, |
| 43 | +) |
| 44 | +from openharness.memory.agent import ( |
| 45 | + ensure_agent_memory_vault, |
| 46 | + get_agent_memory_entrypoint, |
| 47 | + initialize_agent_memory_from_snapshot, |
| 48 | +) |
| 49 | +from openharness.memory.schema import ( |
| 50 | + DEFAULT_MEMORY_SCOPE, |
| 51 | + DEFAULT_MEMORY_TYPE, |
| 52 | + MEMORY_TYPES, |
| 53 | + is_disabled_metadata, |
| 54 | + is_memory_expired, |
| 55 | + parse_memory_scope, |
| 56 | + parse_memory_type, |
| 57 | + split_memory_file, |
| 58 | +) |
| 59 | +from openharness.memory.team import ( |
| 60 | + check_team_memory_secrets, |
| 61 | + ensure_team_memory_vault, |
| 62 | + get_team_memory_dir, |
41 | 63 | ) |
42 | | -from openharness.memory.schema import is_disabled_metadata, is_memory_expired, split_memory_file |
43 | 64 | from openharness.output_styles import load_output_styles |
44 | 65 | from openharness.permissions import PermissionChecker, PermissionMode |
45 | 66 | from openharness.plugins import load_plugins |
|
60 | 81 | restore_memory_backup, |
61 | 82 | start_dream_now, |
62 | 83 | ) |
| 84 | +from openharness.services.memory_extract import extract_memories_from_turn |
| 85 | +from openharness.services.session_memory import ( |
| 86 | + get_session_memory_content, |
| 87 | + get_session_memory_path, |
| 88 | + update_session_memory_file, |
| 89 | +) |
63 | 90 | from openharness.services.session_backend import DEFAULT_SESSION_BACKEND, SessionBackend |
64 | 91 | from openharness.skills import load_skill_registry |
65 | 92 | from openharness.skills.types import SkillDefinition |
@@ -654,7 +681,10 @@ async def _memory_handler(args: str, context: CommandContext) -> CommandResult: |
654 | 681 | message=( |
655 | 682 | f"Memory store: {backend.label}\n" |
656 | 683 | f"Memory directory: {backend.get_memory_dir()}\n" |
657 | | - f"Entrypoint: {backend.get_entrypoint()}" |
| 684 | + f"Entrypoint: {backend.get_entrypoint()}\n" |
| 685 | + "Commands: list, show NAME, add TITLE :: CONTENT, remove NAME, " |
| 686 | + "edit [NAME], validate, extract, session, team, agent, " |
| 687 | + "migrate --dry-run, migrate --apply" |
658 | 688 | ) |
659 | 689 | ) |
660 | 690 | action = tokens[0] |
@@ -710,19 +740,55 @@ async def _memory_handler(args: str, context: CommandContext) -> CommandResult: |
710 | 740 | return CommandResult(message=f"Memory entry not found: {rest}") |
711 | 741 | return CommandResult(message=content) |
712 | 742 | if action == "add" and rest: |
713 | | - title, separator, content = rest.partition("::") |
| 743 | + memory_type, scope, cleaned_rest = _parse_memory_add_flags(rest) |
| 744 | + title, separator, content = cleaned_rest.partition("::") |
714 | 745 | if not separator or not title.strip() or not content.strip(): |
715 | | - return CommandResult(message="Usage: /memory add TITLE :: CONTENT") |
716 | | - path = backend.add_entry(title.strip(), content.strip()) |
| 746 | + return CommandResult(message="Usage: /memory add [--type TYPE] [--scope SCOPE] TITLE :: CONTENT") |
| 747 | + if context.memory_backend is None: |
| 748 | + path = add_memory_entry( |
| 749 | + context.cwd, |
| 750 | + title.strip(), |
| 751 | + content.strip(), |
| 752 | + memory_type=memory_type, |
| 753 | + scope=scope, |
| 754 | + ) |
| 755 | + else: |
| 756 | + path = backend.add_entry(title.strip(), content.strip()) |
717 | 757 | return CommandResult(message=f"Added memory entry {path.name}") |
718 | 758 | if action == "remove" and rest: |
719 | 759 | if backend.remove_entry(rest.strip()): |
720 | 760 | return CommandResult(message=f"Removed memory entry {rest.strip()}") |
721 | 761 | return CommandResult(message=f"Memory entry not found: {rest.strip()}") |
| 762 | + if action == "edit": |
| 763 | + return _handle_memory_edit_command(rest, context, backend) |
| 764 | + if action == "validate": |
| 765 | + return _handle_memory_validate_command(context) |
| 766 | + if action == "extract": |
| 767 | + if context.memory_backend is not None: |
| 768 | + return CommandResult(message="Memory extraction is only supported for OpenHarness project memory.") |
| 769 | + result = await extract_memories_from_turn( |
| 770 | + cwd=context.cwd, |
| 771 | + api_client=context.engine.api_client, |
| 772 | + model=context.engine.model, |
| 773 | + messages=context.engine.messages, |
| 774 | + max_records=load_settings().memory.auto_extract_max_records, |
| 775 | + ) |
| 776 | + if result.skipped: |
| 777 | + return CommandResult(message=f"Memory extraction skipped: {result.reason}") |
| 778 | + return CommandResult( |
| 779 | + message="Memory extraction wrote:\n" + "\n".join(f"- {path}" for path in result.written_paths) |
| 780 | + ) |
| 781 | + if action == "session": |
| 782 | + return _handle_memory_session_command(rest, context) |
| 783 | + if action == "team": |
| 784 | + return _handle_memory_team_command(rest, context) |
| 785 | + if action == "agent": |
| 786 | + return _handle_memory_agent_command(rest, context) |
722 | 787 | return CommandResult( |
723 | 788 | message=( |
724 | 789 | "Usage: /memory " |
725 | | - "[list|show NAME|add TITLE :: CONTENT|remove NAME|" |
| 790 | + "[list|show NAME|add TITLE :: CONTENT|remove NAME|edit [NAME]|" |
| 791 | + "validate|extract|session|team|agent|" |
726 | 792 | "migrate --dry-run|migrate --apply]" |
727 | 793 | ) |
728 | 794 | ) |
@@ -2478,6 +2544,158 @@ async def _plugin_command_handler( |
2478 | 2544 | return registry |
2479 | 2545 |
|
2480 | 2546 |
|
| 2547 | +def _handle_memory_edit_command( |
| 2548 | + args: str, |
| 2549 | + context: CommandContext, |
| 2550 | + backend: MemoryCommandBackend, |
| 2551 | +) -> CommandResult: |
| 2552 | + memory_dir = backend.get_memory_dir() |
| 2553 | + target = backend.get_entrypoint() |
| 2554 | + if args.strip(): |
| 2555 | + path, invalid = _resolve_memory_entry_path(memory_dir, args.strip()) |
| 2556 | + if invalid: |
| 2557 | + return CommandResult(message="Memory entry path must stay within the configured memory directory.") |
| 2558 | + if path is None: |
| 2559 | + return CommandResult(message=f"Memory entry not found: {args.strip()}") |
| 2560 | + target = path |
| 2561 | + target.parent.mkdir(parents=True, exist_ok=True) |
| 2562 | + target.touch(exist_ok=True) |
| 2563 | + editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") |
| 2564 | + if not editor: |
| 2565 | + return CommandResult(message=f"Memory file ready: {target}\nSet $VISUAL or $EDITOR to open it from /memory edit.") |
| 2566 | + result = subprocess.run([editor, str(target)], cwd=context.cwd, check=False) |
| 2567 | + if result.returncode != 0: |
| 2568 | + return CommandResult(message=f"Editor exited with status {result.returncode}: {editor}") |
| 2569 | + return CommandResult(message=f"Edited memory file: {target}") |
| 2570 | + |
| 2571 | + |
| 2572 | +def _parse_memory_add_flags(args: str): |
| 2573 | + """Parse optional ``/memory add`` type/scope flags.""" |
| 2574 | + |
| 2575 | + memory_type = DEFAULT_MEMORY_TYPE |
| 2576 | + scope = DEFAULT_MEMORY_SCOPE |
| 2577 | + rest = args.strip() |
| 2578 | + changed = True |
| 2579 | + while changed: |
| 2580 | + changed = False |
| 2581 | + if rest.startswith("--type "): |
| 2582 | + _, _, tail = rest.partition(" ") |
| 2583 | + raw, _, rest = tail.partition(" ") |
| 2584 | + parsed = parse_memory_type(raw, default=DEFAULT_MEMORY_TYPE) |
| 2585 | + if parsed is not None: |
| 2586 | + memory_type = parsed |
| 2587 | + changed = True |
| 2588 | + if rest.startswith("--scope "): |
| 2589 | + _, _, tail = rest.partition(" ") |
| 2590 | + raw, _, rest = tail.partition(" ") |
| 2591 | + parsed_scope = parse_memory_scope(raw, default=DEFAULT_MEMORY_SCOPE) |
| 2592 | + if parsed_scope is not None: |
| 2593 | + scope = parsed_scope |
| 2594 | + changed = True |
| 2595 | + return memory_type, scope, rest |
| 2596 | + |
| 2597 | + |
| 2598 | +def _handle_memory_validate_command(context: CommandContext) -> CommandResult: |
| 2599 | + memory_dir = get_project_memory_dir(context.cwd) |
| 2600 | + headers = scan_memory_files(context.cwd, max_files=500) |
| 2601 | + issues: list[str] = [] |
| 2602 | + for header in headers: |
| 2603 | + raw_type = header.frontmatter.get("type") or header.frontmatter.get("memory_type") |
| 2604 | + if parse_memory_type(raw_type) is None: |
| 2605 | + issues.append( |
| 2606 | + f"- {header.relative_path}: invalid or missing type {raw_type!r}; expected {', '.join(MEMORY_TYPES)}" |
| 2607 | + ) |
| 2608 | + if "team" in Path(header.relative_path).parts: |
| 2609 | + try: |
| 2610 | + text = header.path.read_text(encoding="utf-8", errors="replace") |
| 2611 | + except OSError: |
| 2612 | + text = "" |
| 2613 | + secret_error = check_team_memory_secrets(text) |
| 2614 | + if secret_error: |
| 2615 | + issues.append(f"- {header.relative_path}: {secret_error}") |
| 2616 | + if not issues: |
| 2617 | + return CommandResult( |
| 2618 | + message=( |
| 2619 | + "Memory validation passed.\n" |
| 2620 | + f"- files: {len(headers)}\n" |
| 2621 | + f"- memory_dir: {memory_dir}" |
| 2622 | + ) |
| 2623 | + ) |
| 2624 | + return CommandResult(message="Memory validation issues:\n" + "\n".join(issues)) |
| 2625 | + |
| 2626 | + |
| 2627 | +def _handle_memory_session_command(args: str, context: CommandContext) -> CommandResult: |
| 2628 | + action = args.split(maxsplit=1)[0] if args.strip() else "status" |
| 2629 | + path = get_session_memory_path(context.cwd, context.session_id or "default") |
| 2630 | + if action == "update": |
| 2631 | + path = update_session_memory_file( |
| 2632 | + context.cwd, |
| 2633 | + context.engine.messages, |
| 2634 | + tool_metadata=context.engine.tool_metadata, |
| 2635 | + session_id=context.session_id or "default", |
| 2636 | + ) |
| 2637 | + return CommandResult(message=f"Updated session memory: {path}") |
| 2638 | + if action == "show": |
| 2639 | + content = get_session_memory_content(path) |
| 2640 | + return CommandResult(message=content or f"No session memory at {path}") |
| 2641 | + return CommandResult( |
| 2642 | + message=( |
| 2643 | + "Session memory:\n" |
| 2644 | + f"- path: {path}\n" |
| 2645 | + f"- exists: {path.exists()}\n" |
| 2646 | + "Commands: /memory session [status|show|update]" |
| 2647 | + ) |
| 2648 | + ) |
| 2649 | + |
| 2650 | + |
| 2651 | +def _handle_memory_team_command(args: str, context: CommandContext) -> CommandResult: |
| 2652 | + action = args.split(maxsplit=1)[0] if args.strip() else "status" |
| 2653 | + team_dir = ensure_team_memory_vault(context.cwd) |
| 2654 | + if action == "list": |
| 2655 | + files = sorted(path for path in team_dir.rglob("*.md") if path.name != "MEMORY.md") |
| 2656 | + return CommandResult(message="\n".join(str(path.relative_to(team_dir)) for path in files) or "No team memory files.") |
| 2657 | + if action == "validate": |
| 2658 | + issues: list[str] = [] |
| 2659 | + for path in sorted(team_dir.rglob("*.md")): |
| 2660 | + if path.name == "MEMORY.md": |
| 2661 | + continue |
| 2662 | + text = path.read_text(encoding="utf-8", errors="replace") |
| 2663 | + secret_error = check_team_memory_secrets(text) |
| 2664 | + if secret_error: |
| 2665 | + issues.append(f"- {path.relative_to(team_dir)}: {secret_error}") |
| 2666 | + return CommandResult(message="Team memory validation passed." if not issues else "\n".join(issues)) |
| 2667 | + return CommandResult( |
| 2668 | + message=( |
| 2669 | + "Team memory:\n" |
| 2670 | + f"- directory: {get_team_memory_dir(context.cwd)}\n" |
| 2671 | + f"- exists: {team_dir.exists()}\n" |
| 2672 | + "Commands: /memory team [status|list|validate]" |
| 2673 | + ) |
| 2674 | + ) |
| 2675 | + |
| 2676 | + |
| 2677 | +def _handle_memory_agent_command(args: str, context: CommandContext) -> CommandResult: |
| 2678 | + parts = args.split() |
| 2679 | + action = parts[0] if parts else "status" |
| 2680 | + agent_type = parts[1] if len(parts) > 1 else "default" |
| 2681 | + scope = parts[2] if len(parts) > 2 else "project" |
| 2682 | + if scope not in {"user", "project", "local"}: |
| 2683 | + return CommandResult(message="Agent memory scope must be one of: user, project, local") |
| 2684 | + if action == "snapshot": |
| 2685 | + target = initialize_agent_memory_from_snapshot(context.cwd, agent_type, scope) # type: ignore[arg-type] |
| 2686 | + return CommandResult(message=f"Initialized agent memory from snapshot: {target}" if target else "No snapshot found.") |
| 2687 | + vault = ensure_agent_memory_vault(context.cwd, agent_type, scope) # type: ignore[arg-type] |
| 2688 | + return CommandResult( |
| 2689 | + message=( |
| 2690 | + "Agent memory:\n" |
| 2691 | + f"- agent_type: {agent_type}\n" |
| 2692 | + f"- scope: {scope}\n" |
| 2693 | + f"- directory: {vault}\n" |
| 2694 | + f"- entrypoint: {get_agent_memory_entrypoint(context.cwd, agent_type, scope)}" |
| 2695 | + ) |
| 2696 | + ) |
| 2697 | + |
| 2698 | + |
2481 | 2699 | def _resolve_memory_entry_path(memory_dir: Path, candidate: str) -> tuple[Path | None, bool]: |
2482 | 2700 | """Resolve a memory entry path while enforcing containment under ``memory_dir``.""" |
2483 | 2701 |
|
|
0 commit comments