diff --git a/cli.py b/cli.py index 01575cb..3d4b333 100644 --- a/cli.py +++ b/cli.py @@ -8,6 +8,7 @@ Command-line interface for the cognitive memory system. import argparse import json import sys +from pathlib import Path from client import CognitiveMemoryClient from common import ( @@ -15,6 +16,7 @@ from common import ( VALID_RELATION_TYPES, VALID_TYPES, _load_memory_config, + create_graph, resolve_graph_path, list_graphs, ) @@ -224,6 +226,19 @@ def main(): # graphs subparsers.add_parser("graphs", help="List available memory graphs") + # graph-create + sp = subparsers.add_parser("graph-create", help="Create a new named memory graph") + sp.add_argument("name", help="Graph name (alphanumeric, hyphens OK)") + sp.add_argument( + "--path", + default=None, + help=( + "Custom directory path for the graph. " + "If omitted, uses the convention path (~/.local/share/cognitive-memory-graphs/). " + "Custom paths are registered in the default graph's _config.json." + ), + ) + args = parser.parse_args() if not args.command: @@ -467,6 +482,10 @@ def main(): elif args.command == "graphs": result = list_graphs() + elif args.command == "graph-create": + custom_path = Path(args.path) if args.path else None + result = create_graph(args.name, path=custom_path) + elif args.command == "config": config_path = client.memory_dir / "_config.json" config = _load_memory_config(config_path) diff --git a/common.py b/common.py index 3d587fe..606eb62 100644 --- a/common.py +++ b/common.py @@ -557,6 +557,50 @@ def resolve_graph_path( return GRAPHS_BASE_DIR / graph_name +def create_graph(name: str, path: Optional[Path] = None) -> Dict[str, Any]: + """Create a new named graph directory structure. + + If path is None, uses the convention path (GRAPHS_BASE_DIR / name) and + does NOT modify any config file — the convention path is auto-discovered. + + If a custom path is given, the mapping is written to the default graph's + _config.json under graphs..path so resolve_graph_path() can find it. + + Returns a dict with keys: name, path, created (bool), registered (bool). + """ + if path is None: + graph_path = GRAPHS_BASE_DIR / name + register = False + else: + graph_path = Path(path).expanduser() + register = True + + # Track whether this is a new graph or already exists + already_existed = graph_path.exists() + + # Create the standard subdirectory layout that CognitiveMemoryClient expects + for type_dir in TYPE_DIRS.values(): + (graph_path / "graph" / type_dir).mkdir(parents=True, exist_ok=True) + (graph_path / "graph" / EDGES_DIR_NAME).mkdir(parents=True, exist_ok=True) + (graph_path / "episodes").mkdir(parents=True, exist_ok=True) + (graph_path / "vault").mkdir(parents=True, exist_ok=True) + + # Register custom path in the default graph's _config.json + if register: + cfg = _load_memory_config(CONFIG_PATH) + graphs_section = cfg.setdefault("graphs", {}) + graphs_section[name] = {"path": str(graph_path)} + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + CONFIG_PATH.write_text(json.dumps(cfg, indent=2)) + + return { + "name": name, + "path": str(graph_path), + "created": not already_existed, + "registered": register, + } + + def list_graphs(config_path: Optional[Path] = None) -> List[Dict[str, Any]]: """List all known graphs: default + configured + discovered on disk.""" result = [{"name": "default", "path": str(MEMORY_DIR)}] diff --git a/edges.py b/edges.py index 033acdb..a7769c0 100644 --- a/edges.py +++ b/edges.py @@ -43,7 +43,11 @@ class EdgesMixin: from_path = self._resolve_memory_path(from_id) to_path = self._resolve_memory_path(to_id) if not from_path or not to_path: - raise ValueError(f"Memory not found: {from_id if not from_path else to_id}") + missing_id = from_id if not from_path else to_id + raise ValueError( + f"Memory not found: {missing_id}. " + f"Note: edges can only connect memories within the same graph." + ) # Read source memory fm, body = self._read_memory_file(from_path) diff --git a/scripts/edge-proposer.py b/scripts/edge-proposer.py index 813e48a..c71c14b 100644 --- a/scripts/edge-proposer.py +++ b/scripts/edge-proposer.py @@ -33,6 +33,7 @@ First run: 2026-02-19 — produced 5186 candidates from 473 memories, 20 high-quality edges were manually selected and created. """ +import argparse import json import os import re @@ -40,20 +41,53 @@ from pathlib import Path from collections import defaultdict from itertools import combinations -# Resolve data directory: COGNITIVE_MEMORY_DIR > XDG_DATA_HOME > default +# Resolve base data directory: COGNITIVE_MEMORY_DIR > XDG_DATA_HOME > default _env_dir = os.environ.get("COGNITIVE_MEMORY_DIR", "") if _env_dir: - MEMORY_DIR = Path(_env_dir).expanduser() + _BASE_MEMORY_DIR = Path(_env_dir).expanduser() else: _xdg_data = os.environ.get("XDG_DATA_HOME", "") or str( Path.home() / ".local" / "share" ) - MEMORY_DIR = Path(_xdg_data) / "cognitive-memory" + _BASE_MEMORY_DIR = Path(_xdg_data) / "cognitive-memory" +# These are set at runtime in main() after --graph is resolved +MEMORY_DIR = _BASE_MEMORY_DIR STATE_FILE = MEMORY_DIR / "_state.json" GRAPH_DIR = MEMORY_DIR / "graph" EDGES_DIR = GRAPH_DIR / "edges" + +def _resolve_graph_dir(graph_name: str | None) -> Path: + """Resolve a graph name to its directory path. + + Mirrors the logic in common.resolve_graph_path without importing the full + client package (which would pull in heavy dependencies). + + None / 'default' -> MEMORY_DIR (the default graph). + Named graph -> sibling directory next to MEMORY_DIR, e.g. + ~/.local/share/cognitive-memory-. + """ + if not graph_name or graph_name == "default": + return _BASE_MEMORY_DIR + + # Check _config.json for a registered path + config_path = _BASE_MEMORY_DIR / "_config.json" + if config_path.exists(): + try: + cfg = json.loads(config_path.read_text()) + graphs = cfg.get("graphs", {}) + if graph_name in graphs: + p = graphs[graph_name].get("path", "") + if p: + return Path(p).expanduser() + except Exception: + pass + + # Convention: ~/.local/share/cognitive-memory-graphs/ + return _BASE_MEMORY_DIR.parent / "cognitive-memory-graphs" / graph_name + + # Type-based heuristics: (type_a, type_b) -> (suggested_rel, direction, base_score) # direction: "ab" means a->b, "ba" means b->a TYPE_HEURISTICS = { @@ -349,6 +383,31 @@ def score_pair(mem_a: dict, mem_b: dict) -> dict | None: def main(): + global MEMORY_DIR, STATE_FILE, GRAPH_DIR, EDGES_DIR + + parser = argparse.ArgumentParser( + description="Analyze cognitive memories and propose high-quality edges." + ) + parser.add_argument( + "--graph", + default=None, + metavar="NAME", + help=( + "Named memory graph to analyze (default: the default graph). " + "Use 'claude-memory graphs' to list available graphs." + ), + ) + args = parser.parse_args() + + # Resolve graph directory and update module-level path globals + MEMORY_DIR = _resolve_graph_dir(args.graph) + STATE_FILE = MEMORY_DIR / "_state.json" + GRAPH_DIR = MEMORY_DIR / "graph" + EDGES_DIR = GRAPH_DIR / "edges" + + if args.graph: + print(f"Using graph: {args.graph} ({MEMORY_DIR})") + print("Loading memories...") memories = load_memories() print(f" Found {len(memories)} memories") diff --git a/scripts/maintain-all-graphs.sh b/scripts/maintain-all-graphs.sh new file mode 100755 index 0000000..4688152 --- /dev/null +++ b/scripts/maintain-all-graphs.sh @@ -0,0 +1,135 @@ +#!/bin/bash +# Run cognitive memory maintenance (decay, core, embed, reflect) for ALL graphs. +# +# Called by systemd service units instead of individual claude-memory commands, +# so that named graphs receive the same maintenance as the default graph. +# +# Usage: +# maintain-all-graphs.sh [--daily | --embed | --weekly] +# +# --daily (default) Run decay + core for every graph +# --embed Run embed --if-changed for every graph +# --weekly Run reflect for every graph +# +# Named graph directories that do not exist on disk are silently skipped. +# The default graph is always processed first regardless of disk discovery. + +set -euo pipefail + +# ── configuration ────────────────────────────────────────────────────────────── + +CLAUDE_MEMORY="${CLAUDE_MEMORY_BIN:-/home/cal/.local/bin/claude-memory}" +export PATH="/home/cal/.local/bin:$PATH" + +MODE="${1:---daily}" + +# ── helpers ──────────────────────────────────────────────────────────────────── + +log() { + echo "maintain-all-graphs [$(date '+%H:%M:%S')]: $*" +} + +# Run a claude-memory command for one graph. +# $1 = graph name ("default" omits --graph flag, others pass --graph ) +# remaining args = claude-memory subcommand + flags +run_for_graph() { + local graph_name="$1" + shift + local graph_flag=() + if [ "$graph_name" != "default" ]; then + graph_flag=(--graph "$graph_name") + fi + + log "[$graph_name] $*" + "$CLAUDE_MEMORY" "${graph_flag[@]}" "$@" +} + +# ── discover graphs ──────────────────────────────────────────────────────────── +# +# claude-memory graphs outputs a JSON array: +# [{"name": "default", "path": "/home/cal/.local/share/cognitive-memory"}, ...] +# +# We parse it with only sh/awk — no jq dependency. +# Strategy: extract "name" and "path" values line by line from pretty-printed JSON. + +graphs_json=$("$CLAUDE_MEMORY" graphs 2>/dev/null) || { + log "ERROR: 'claude-memory graphs' failed; aborting" + exit 1 +} + +# Build parallel arrays: GRAPH_NAMES and GRAPH_PATHS +GRAPH_NAMES=() +GRAPH_PATHS=() + +# State machine: after seeing "name": capture value; after "path": capture value. +# Each object ends when we collect both; append to arrays. +_name="" +_path="" +while IFS= read -r line; do + # Strip leading/trailing whitespace + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + + # Match: "name": "value" + if [[ "$line" =~ ^\"name\":\ *\"([^\"]+)\" ]]; then + _name="${BASH_REMATCH[1]}" + fi + # Match: "path": "value" (path may contain slashes — [^"]+ is fine) + if [[ "$line" =~ ^\"path\":\ *\"([^\"]+)\" ]]; then + _path="${BASH_REMATCH[1]}" + fi + # When we have both, flush to arrays (object boundary) + if [ -n "$_name" ] && [ -n "$_path" ]; then + GRAPH_NAMES+=("$_name") + GRAPH_PATHS+=("$_path") + _name="" + _path="" + fi +done <<< "$graphs_json" + +if [ "${#GRAPH_NAMES[@]}" -eq 0 ]; then + log "ERROR: no graphs discovered; aborting" + exit 1 +fi + +log "discovered ${#GRAPH_NAMES[@]} graph(s): ${GRAPH_NAMES[*]}" + +# ── main loop ───────────────────────────────────────────────────────────────── + +ERRORS=0 + +for i in "${!GRAPH_NAMES[@]}"; do + name="${GRAPH_NAMES[$i]}" + path="${GRAPH_PATHS[$i]}" + + # Skip graphs whose directory doesn't exist on disk (named graphs may not + # be initialised yet on this machine). + if [ "$name" != "default" ] && [ ! -d "$path" ]; then + log "[$name] skipping — directory not found: $path" + continue + fi + + case "$MODE" in + --daily) + run_for_graph "$name" decay || { log "[$name] decay FAILED"; ERRORS=$((ERRORS+1)); } + run_for_graph "$name" core || { log "[$name] core FAILED"; ERRORS=$((ERRORS+1)); } + ;; + --embed) + run_for_graph "$name" embed --if-changed || { log "[$name] embed FAILED"; ERRORS=$((ERRORS+1)); } + ;; + --weekly) + run_for_graph "$name" reflect || { log "[$name] reflect FAILED"; ERRORS=$((ERRORS+1)); } + ;; + *) + echo "Usage: $0 [--daily | --embed | --weekly]" >&2 + exit 1 + ;; + esac +done + +if [ "$ERRORS" -gt 0 ]; then + log "completed with $ERRORS error(s)" + exit 1 +fi + +log "done (mode: $MODE)" diff --git a/scripts/memory-git-sync.sh b/scripts/memory-git-sync.sh index a4ce16e..5d14871 100755 --- a/scripts/memory-git-sync.sh +++ b/scripts/memory-git-sync.sh @@ -1,47 +1,103 @@ #!/bin/bash -# Commit and push cognitive memory changes to Gitea (cal/claude-memory) +# Commit and push cognitive memory changes to Gitea. # -# Called daily by cognitive-memory-daily.service after decay/core/symlinks. -# Only commits if there are actual changes. Safe to run multiple times. +# Syncs the default graph and any named graphs whose directory is a git repo. +# Called daily by cognitive-memory-daily.service after decay/core runs. +# Safe to run multiple times — only commits when there are actual changes. +# +# Default graph: COGNITIVE_MEMORY_DIR > XDG_DATA_HOME > ~/.local/share/cognitive-memory +# Named graphs: ~/.local/share/cognitive-memory-graphs// (any with .git/) # # Location: ~/.claude/skills/cognitive-memory/scripts/memory-git-sync.sh -# Repo: cognitive-memory data dir -> https://git.manticorum.com/cal/claude-memory.git -set -euo pipefail +set -uo pipefail +# Note: -e intentionally omitted at the top level so that one graph's failure +# does not prevent the remaining graphs from being synced. Each function call +# is checked explicitly. + +# ── resolve directories ──────────────────────────────────────────────────────── -# Resolve data directory: COGNITIVE_MEMORY_DIR > XDG_DATA_HOME > default MEMORY_DIR="${COGNITIVE_MEMORY_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/cognitive-memory}" -cd "$MEMORY_DIR" +# Named graphs live in a sibling directory: cognitive-memory-graphs// +GRAPHS_BASE_DIR="${MEMORY_DIR%cognitive-memory}cognitive-memory-graphs" -# Check if there are any changes to commit -if git diff --quiet && git diff --cached --quiet && [ -z "$(git ls-files --others --exclude-standard)" ]; then - echo "memory-git-sync: no changes to commit" - exit 0 -fi +# ── sync function ────────────────────────────────────────────────────────────── +# sync_repo