feat: complete multi-graph support across CLI, scripts, and systemd timers

Non-default graphs were second-class citizens — timers only maintained the
default graph, git sync ignored named graphs, there was no way to create
a graph without editing config manually, cross-graph edge errors were
confusing, and utility scripts were hardcoded to the default graph.

- Add `graph-create` CLI command + `create_graph()` in common.py, with
  custom path registration written to the default graph's _config.json
- Add `scripts/maintain-all-graphs.sh` to loop decay/core/embed/reflect
  over all discovered graphs; update systemd services to call it
- Refactor `memory-git-sync.sh` into sync_repo() function that iterates
  default + all named graphs with .git directories
- Improve cross-graph edge ValueError to explain the same-graph constraint
- Add --graph flag to edge-proposer.py and session_memory.py
- Update systemd/README.md with portable paths and new architecture

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-02-28 22:35:55 -06:00
parent adc9a64c8d
commit d8dd1f35a5
11 changed files with 424 additions and 52 deletions

19
cli.py
View File

@ -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/<name>). "
"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)

View File

@ -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.<name>.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)}]

View File

@ -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)

View File

@ -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-<name>.
"""
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/<name>
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")

135
scripts/maintain-all-graphs.sh Executable file
View File

@ -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 <name>)
# 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)"

View File

@ -1,29 +1,57 @@
#!/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/<name>/ (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/<name>/
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"
# ── sync function ──────────────────────────────────────────────────────────────
# sync_repo <dir> <label>
# Commits and pushes any pending changes in the given git repo directory.
# Returns 0 on success or "nothing to commit", non-zero on error.
sync_repo() {
local dir="$1"
local label="$2"
# Directory must exist and be a git repo
if [ ! -d "$dir/.git" ]; then
echo "memory-git-sync [$label]: not a git repo, skipping"
return 0
fi
(
# Run everything inside a subshell so cd is scoped and set -e is safe
set -euo pipefail
cd "$dir"
# Nothing to commit?
if git diff --quiet && git diff --cached --quiet \
&& [ -z "$(git ls-files --others --exclude-standard)" ]; then
echo "memory-git-sync [$label]: no changes to commit"
exit 0
fi
# Stage all changes (.gitignore handles exclusions)
git add -A
# Build a commit message from what changed
# Build a descriptive commit message
ADDED=$(git diff --cached --name-only --diff-filter=A | wc -l)
MODIFIED=$(git diff --cached --name-only --diff-filter=M | wc -l)
DELETED=$(git diff --cached --name-only --diff-filter=D | wc -l)
@ -35,13 +63,41 @@ if [ "$EDGES" -gt 0 ]; then
fi
git commit -m "$MSG" --no-gpg-sign 2>/dev/null || {
echo "memory-git-sync: commit failed (pre-commit hook?)"
echo "memory-git-sync [$label]: commit failed (pre-commit hook?)"
exit 1
}
git push origin main 2>/dev/null || {
echo "memory-git-sync: push failed"
echo "memory-git-sync [$label]: push failed"
exit 1
}
echo "memory-git-sync: pushed to origin/main"
echo "memory-git-sync [$label]: pushed to origin/main"
)
}
# ── sync default graph ─────────────────────────────────────────────────────────
ERRORS=0
sync_repo "$MEMORY_DIR" "default" || ERRORS=$((ERRORS + 1))
# ── sync named graphs ──────────────────────────────────────────────────────────
# Iterate subdirectories of GRAPHS_BASE_DIR; skip non-git directories silently.
if [ -d "$GRAPHS_BASE_DIR" ]; then
for graph_dir in "$GRAPHS_BASE_DIR"/*/; do
# glob may yield literal "*/'" if directory is empty
[ -d "$graph_dir" ] || continue
graph_name="$(basename "$graph_dir")"
sync_repo "$graph_dir" "$graph_name" || ERRORS=$((ERRORS + 1))
done
fi
# ── exit status ───────────────────────────────────────────────────────────────
if [ "$ERRORS" -gt 0 ]; then
echo "memory-git-sync: completed with $ERRORS error(s)"
exit 1
fi

View File

@ -7,6 +7,7 @@ architecture decisions, new patterns, configurations), and stores them as
cognitive memories via claude-memory CLI.
"""
import argparse
import json
import re
import subprocess
@ -480,8 +481,13 @@ def build_title(summary: dict) -> str:
return f"[{project}] Session: {work}"
def store_memory(summary: dict):
"""Store the session memory via claude-memory CLI."""
def store_memory(summary: dict, graph: str | None = None):
"""Store the session memory via claude-memory CLI.
Args:
summary: Session summary dict from build_session_summary().
graph: Named memory graph to store into, or None for the default graph.
"""
title = build_title(summary)
content = build_memory_content(summary)
mem_type = determine_memory_type(summary)
@ -501,8 +507,11 @@ def store_memory(summary: dict):
tags.append("session-log")
tag_str = ",".join(tags)
cmd = [
"claude-memory",
# Base command: optionally target a named graph
cmd = ["claude-memory"]
if graph:
cmd += ["--graph", graph]
cmd += [
"store",
"--type",
mem_type,
@ -544,6 +553,24 @@ def store_memory(summary: dict):
def main():
log_separator()
parser = argparse.ArgumentParser(
description="Session-end memory hook: store session events as cognitive memories.",
add_help=False, # keep --help available but don't conflict with hook stdin
)
parser.add_argument(
"--graph",
default=None,
metavar="NAME",
help=(
"Named memory graph to store memories into (default: the default graph). "
"Use 'claude-memory graphs' to list available graphs."
),
)
parser.add_argument(
"--help", "-h", action="help", help="Show this help message and exit."
)
args, _ = parser.parse_known_args()
hook_input = read_stdin()
transcript_path = hook_input.get("transcript_path", "")
cwd = hook_input.get("cwd", "")
@ -579,7 +606,7 @@ def main():
log(f"[main] ABORT: build_session_summary returned '{summary}'")
sys.exit(0)
store_memory(summary)
store_memory(summary, graph=args.graph)
log("[main] Done")

View File

@ -6,16 +6,31 @@ Reference copies of the systemd user units that automate memory maintenance.
| Unit | Schedule | What it does |
|------|----------|-------------|
| `cognitive-memory-daily` | daily | Decay scores, regenerate CORE.md, refresh MEMORY.md symlinks |
| `cognitive-memory-embed` | hourly | Refresh embeddings (skips if unchanged) |
| `cognitive-memory-weekly` | weekly | Run reflection cycle |
| `cognitive-memory-daily` | daily | Decay scores, regenerate CORE.md, git sync — for ALL graphs |
| `cognitive-memory-embed` | hourly | Refresh embeddings (skips if unchanged) — for ALL graphs |
| `cognitive-memory-weekly` | weekly | Run reflection cycle — for ALL graphs |
All three services now call `scripts/maintain-all-graphs.sh` instead of
individual `claude-memory` commands. The script discovers every graph via
`claude-memory graphs` (default graph + named graphs under
`~/.local/share/cognitive-memory-graphs/`) and runs the appropriate command
for each one, passing `--graph <name>` for non-default graphs.
Named graph directories that do not exist on disk are silently skipped.
## Scripts
| Script | Purpose |
|--------|---------|
| `scripts/maintain-all-graphs.sh` | Loop decay/core/embed/reflect over all graphs |
| `scripts/memory-git-sync.sh` | Git commit+push for default graph and all named git repos |
## Install / Update
```bash
# Copy units into place
cp ~/.claude/skills/cognitive-memory/systemd/*.service \
~/.claude/skills/cognitive-memory/systemd/*.timer \
# Copy units into place (adjust source path to your cognitive-memory install)
CMDIR=~/.claude/skills/cognitive-memory # or /mnt/NV2/Development/cognitive-memory
cp "$CMDIR"/systemd/*.service "$CMDIR"/systemd/*.timer \
~/.config/systemd/user/
# Reload and enable
@ -32,3 +47,16 @@ systemctl --user list-timers 'cognitive-memory-*'
systemctl --user start cognitive-memory-daily.service # manual test run
journalctl --user -u cognitive-memory-daily.service --since today
```
## Manual test (single graph)
```bash
# Default graph only
claude-memory decay && claude-memory core
# Named graph
claude-memory --graph paper-dynasty decay && claude-memory --graph paper-dynasty core
# All graphs at once (adjust path to your install)
/path/to/cognitive-memory/scripts/maintain-all-graphs.sh --daily
```

View File

@ -1,6 +1,6 @@
[Unit]
Description=Cognitive Memory daily maintenance (decay, core, git sync)
Description=Cognitive Memory daily maintenance (decay, core, git sync) for all graphs
[Service]
Type=oneshot
ExecStart=/bin/bash -c 'export PATH="/home/cal/.local/bin:$PATH" && /home/cal/.local/bin/claude-memory decay && /home/cal/.local/bin/claude-memory core && /mnt/NV2/Development/cognitive-memory/scripts/memory-git-sync.sh'
ExecStart=/bin/bash -c 'export PATH="/home/cal/.local/bin:$PATH" && /mnt/NV2/Development/cognitive-memory/scripts/maintain-all-graphs.sh --daily && /mnt/NV2/Development/cognitive-memory/scripts/memory-git-sync.sh'

View File

@ -1,6 +1,6 @@
[Unit]
Description=Cognitive Memory hourly embedding refresh (skips if unchanged)
Description=Cognitive Memory hourly embedding refresh (skips if unchanged) for all graphs
[Service]
Type=oneshot
ExecStart=/bin/bash -c 'export PATH="/home/cal/.local/bin:$PATH" && /home/cal/.local/bin/claude-memory embed --if-changed'
ExecStart=/bin/bash -c 'export PATH="/home/cal/.local/bin:$PATH" && /mnt/NV2/Development/cognitive-memory/scripts/maintain-all-graphs.sh --embed'

View File

@ -1,6 +1,6 @@
[Unit]
Description=Cognitive Memory weekly reflection
Description=Cognitive Memory weekly reflection for all graphs
[Service]
Type=oneshot
ExecStart=/home/cal/.local/bin/claude-memory reflect
ExecStart=/bin/bash -c 'export PATH="/home/cal/.local/bin:$PATH" && /mnt/NV2/Development/cognitive-memory/scripts/maintain-all-graphs.sh --weekly'