Add edge emphasis to cognitive memory skill and edge-proposer script

- SKILL.md: New workflow pattern for creating edges between memories
- SKILL.md: Added proactive trigger for edge creation after storing memories
- SKILL.md: Updated bug fix example to show relating solution to problem
- scripts/edge-proposer.py: Retroactive edge candidate analysis tool
  Scores memory pairs by type heuristics, tag overlap, and content similarity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-02-19 23:31:07 -06:00
parent 42d180ec82
commit a189bb546d
9 changed files with 4450 additions and 2342 deletions

File diff suppressed because it is too large Load Diff

View File

@ -44,7 +44,7 @@
"agent-flag": 667
},
"memoryUsageCount": 18,
"promptQueueUseCount": 4559,
"promptQueueUseCount": 4571,
"cachedStatsigGates": {
"tengu_disable_bypass_permissions_mode": false,
"tengu_use_file_checkpoints": true,
@ -2022,7 +2022,7 @@
"lastOnboardingVersion": "2.0.13",
"subscriptionNoticeCount": 0,
"hasAvailableSubscription": false,
"changelogLastFetched": 1771560193427,
"changelogLastFetched": 1771561618505,
"fallbackAvailableWarningThreshold": 0.2,
"hasIdeOnboardingBeenShown": {
"vscode": true
@ -2199,8 +2199,8 @@
"lastUsedAt": 1770839993137
},
"backlog": {
"usageCount": 4,
"lastUsedAt": 1770912327282
"usageCount": 5,
"lastUsedAt": 1771561009889
},
"keybindings-help": {
"usageCount": 1,
@ -2223,8 +2223,8 @@
"lastUsedAt": 1771199427014
},
"cognitive-memory": {
"usageCount": 35,
"lastUsedAt": 1771560320138
"usageCount": 36,
"lastUsedAt": 1771564275406
},
"commit-push": {
"usageCount": 17,
@ -2259,8 +2259,8 @@
"lastUsedAt": 1771536951910
},
"save-memories": {
"usageCount": 7,
"lastUsedAt": 1771559941495
"usageCount": 9,
"lastUsedAt": 1771561845350
}
},
"opusProMigrationComplete": true,
@ -2269,7 +2269,7 @@
"passesLastSeenRemaining": 3,
"clientDataCache": {
"data": {},
"timestamp": 1771560749962
"timestamp": 1771564332728
},
"hasShownOpus46Notice": {
"57783733-6e1e-48d5-9cb7-fa588a77b795": true

View File

@ -44,7 +44,7 @@
"agent-flag": 667
},
"memoryUsageCount": 18,
"promptQueueUseCount": 4559,
"promptQueueUseCount": 4571,
"cachedStatsigGates": {
"tengu_disable_bypass_permissions_mode": false,
"tengu_use_file_checkpoints": true,
@ -2022,7 +2022,7 @@
"lastOnboardingVersion": "2.0.13",
"subscriptionNoticeCount": 0,
"hasAvailableSubscription": false,
"changelogLastFetched": 1771560193427,
"changelogLastFetched": 1771564367705,
"fallbackAvailableWarningThreshold": 0.2,
"hasIdeOnboardingBeenShown": {
"vscode": true
@ -2199,8 +2199,8 @@
"lastUsedAt": 1770839993137
},
"backlog": {
"usageCount": 4,
"lastUsedAt": 1770912327282
"usageCount": 5,
"lastUsedAt": 1771561009889
},
"keybindings-help": {
"usageCount": 1,
@ -2211,8 +2211,8 @@
"lastUsedAt": 1770959377885
},
"sync-config": {
"usageCount": 25,
"lastUsedAt": 1771539739755
"usageCount": 26,
"lastUsedAt": 1771560749921
},
"claude-optimised": {
"usageCount": 4,
@ -2223,8 +2223,8 @@
"lastUsedAt": 1771199427014
},
"cognitive-memory": {
"usageCount": 35,
"lastUsedAt": 1771560320138
"usageCount": 36,
"lastUsedAt": 1771564275406
},
"commit-push": {
"usageCount": 17,
@ -2259,8 +2259,8 @@
"lastUsedAt": 1771536951910
},
"save-memories": {
"usageCount": 7,
"lastUsedAt": 1771559941495
"usageCount": 9,
"lastUsedAt": 1771561845350
}
},
"opusProMigrationComplete": true,
@ -2269,7 +2269,7 @@
"passesLastSeenRemaining": 3,
"clientDataCache": {
"data": {},
"timestamp": 1771560553737
"timestamp": 1771564371989
},
"hasShownOpus46Notice": {
"57783733-6e1e-48d5-9cb7-fa588a77b795": true

View File

@ -44,7 +44,7 @@
"agent-flag": 667
},
"memoryUsageCount": 18,
"promptQueueUseCount": 4559,
"promptQueueUseCount": 4571,
"cachedStatsigGates": {
"tengu_disable_bypass_permissions_mode": false,
"tengu_use_file_checkpoints": true,
@ -2022,7 +2022,7 @@
"lastOnboardingVersion": "2.0.13",
"subscriptionNoticeCount": 0,
"hasAvailableSubscription": false,
"changelogLastFetched": 1771560193427,
"changelogLastFetched": 1771564367705,
"fallbackAvailableWarningThreshold": 0.2,
"hasIdeOnboardingBeenShown": {
"vscode": true
@ -2199,8 +2199,8 @@
"lastUsedAt": 1770839993137
},
"backlog": {
"usageCount": 4,
"lastUsedAt": 1770912327282
"usageCount": 5,
"lastUsedAt": 1771561009889
},
"keybindings-help": {
"usageCount": 1,
@ -2211,8 +2211,8 @@
"lastUsedAt": 1770959377885
},
"sync-config": {
"usageCount": 25,
"lastUsedAt": 1771539739755
"usageCount": 26,
"lastUsedAt": 1771560749921
},
"claude-optimised": {
"usageCount": 4,
@ -2223,8 +2223,8 @@
"lastUsedAt": 1771199427014
},
"cognitive-memory": {
"usageCount": 35,
"lastUsedAt": 1771560320138
"usageCount": 36,
"lastUsedAt": 1771564275406
},
"commit-push": {
"usageCount": 17,
@ -2259,8 +2259,8 @@
"lastUsedAt": 1771536951910
},
"save-memories": {
"usageCount": 7,
"lastUsedAt": 1771559941495
"usageCount": 9,
"lastUsedAt": 1771561845350
}
},
"opusProMigrationComplete": true,
@ -2269,7 +2269,7 @@
"passesLastSeenRemaining": 3,
"clientDataCache": {
"data": {},
"timestamp": 1771560601674
"timestamp": 1771564603567
},
"hasShownOpus46Notice": {
"57783733-6e1e-48d5-9cb7-fa588a77b795": true

View File

@ -18,7 +18,7 @@
"continue": 665,
"ide-hotkey": 74,
"terminal-setup": 29,
"custom-commands": 655,
"custom-commands": 670,
"shift-enter": 661,
"shift-tab": 665,
"custom-agents": 663,
@ -32,7 +32,7 @@
"ide-upsell-external-terminal": 668,
"stickers-command": 416,
"default-permission-mode-config": 436,
"rename-conversation": 655,
"rename-conversation": 670,
"frontend-design-plugin": 560,
"config-thinking-mode": 412,
"guest-passes": 587,
@ -44,7 +44,7 @@
"agent-flag": 667
},
"memoryUsageCount": 18,
"promptQueueUseCount": 4558,
"promptQueueUseCount": 4571,
"cachedStatsigGates": {
"tengu_disable_bypass_permissions_mode": false,
"tengu_use_file_checkpoints": true,
@ -2022,7 +2022,7 @@
"lastOnboardingVersion": "2.0.13",
"subscriptionNoticeCount": 0,
"hasAvailableSubscription": false,
"changelogLastFetched": 1771560193427,
"changelogLastFetched": 1771564367705,
"fallbackAvailableWarningThreshold": 0.2,
"hasIdeOnboardingBeenShown": {
"vscode": true
@ -2199,8 +2199,8 @@
"lastUsedAt": 1770839993137
},
"backlog": {
"usageCount": 4,
"lastUsedAt": 1770912327282
"usageCount": 5,
"lastUsedAt": 1771561009889
},
"keybindings-help": {
"usageCount": 1,
@ -2211,8 +2211,8 @@
"lastUsedAt": 1770959377885
},
"sync-config": {
"usageCount": 25,
"lastUsedAt": 1771539739755
"usageCount": 26,
"lastUsedAt": 1771560749921
},
"claude-optimised": {
"usageCount": 4,
@ -2223,8 +2223,8 @@
"lastUsedAt": 1771199427014
},
"cognitive-memory": {
"usageCount": 35,
"lastUsedAt": 1771560320138
"usageCount": 36,
"lastUsedAt": 1771564275406
},
"commit-push": {
"usageCount": 17,
@ -2259,8 +2259,8 @@
"lastUsedAt": 1771536951910
},
"save-memories": {
"usageCount": 7,
"lastUsedAt": 1771559941495
"usageCount": 9,
"lastUsedAt": 1771561845350
}
},
"opusProMigrationComplete": true,
@ -2269,7 +2269,7 @@
"passesLastSeenRemaining": 3,
"clientDataCache": {
"data": {},
"timestamp": 1771560509737
"timestamp": 1771565271516
},
"hasShownOpus46Notice": {
"57783733-6e1e-48d5-9cb7-fa588a77b795": true

File diff suppressed because it is too large Load Diff

View File

@ -170,7 +170,7 @@ decay_score = importance × e^(-0.03 × days_since_access) × log2(access_count
## Workflow Patterns
### 1. Store a Bug Fix
### 1. Store a Bug Fix (with Edge)
```bash
# Store the solution
@ -178,6 +178,12 @@ claude-memory store --type solution \
--title "Fixed Redis connection timeouts" \
--content "Added socket_keepalive=True and socket_timeout=300..." \
--tags "redis,timeout,production" --importance 0.8
# Returns: memory_id = abc-123
# Link it to the problem memory that prompted the fix
# (use memory_recall or memory_search to find the related memory first)
claude-memory relate abc-123 def-456 SOLVES \
--description "Keepalive fix resolves the idle timeout disconnections"
# Log the episode
claude-memory episode --type fix --title "Fixed Redis timeouts" \
@ -256,7 +262,38 @@ claude-memory tags related "python"
claude-memory tags suggest <memory_id>
```
### 8. Session Maintenance
### 8. Create Edges Between Related Memories
Edges are first-class relationship objects that connect memories into a traversable graph. Always look for opportunities to link memories — this makes recall and future RAG retrieval significantly richer.
**Relation types:** `SOLVES`, `CAUSES`, `BUILDS_ON`, `ALTERNATIVE_TO`, `REQUIRES`, `FOLLOWS`, `RELATED_TO`
```bash
# Via MCP (preferred in Claude Code sessions):
# memory_relate(from_id, to_id, rel_type, description, strength)
# Via CLI:
claude-memory relate <from_id> <to_id> BUILDS_ON \
--description "Extended the deploy procedure with rollback steps"
# Find what a memory is connected to (up to 3 hops deep)
claude-memory related <memory_id> --max-depth 2
# Search edges by type or connected memory
claude-memory edge-search --types SOLVES
claude-memory edge-search --from <memory_id>
```
**When to create edges:**
- Solution fixes a known problem → `SOLVES`
- New memory extends or refines an earlier one → `BUILDS_ON`
- Error leads to a known failure mode → `CAUSES`
- Two approaches to the same problem → `ALTERNATIVE_TO`
- Memory depends on another being true/done first → `REQUIRES`
- Steps in a sequence → `FOLLOWS`
- Conceptually related but no specific type fits → `RELATED_TO`
### 9. Session Maintenance
```bash
# Recalculate decay scores
@ -345,7 +382,8 @@ This skill should be used proactively when:
8. **Multi-step workflow documented** - Use `procedure` type with structured steps/preconditions/postconditions
9. **Periodically** - Run `reflect` to cluster memories and surface cross-cutting insights. Run `tags suggest` to find missing tag connections
10. **After adding memories** - Run `embed` to refresh semantic search index
11. **Session ending** - Prompt: "Should I store today's learnings?"
11. **Related memories exist** - Create edges to connect them. After storing a new memory, check if it relates to existing ones (SOLVES a problem, BUILDS_ON a prior solution, is ALTERNATIVE_TO another approach, etc.). Well-connected memories produce richer recall and graph traversal for RAG retrieval.
12. **Session ending** - Prompt: "Should I store today's learnings?"
---

View File

@ -0,0 +1,417 @@
#!/usr/bin/env python3
"""Analyze cognitive memories and propose high-quality edges.
Reads all active/fading memories (decay >= 0.2), groups by shared tags,
and scores candidate relationships based on three signals:
- Type heuristics (40%): e.g. fix+problem SOLVES, decision+solution BUILDS_ON
- Tag overlap (30%): Jaccard similarity of tag sets
- Content similarity (30%): Keyword overlap in memory body text
Outputs ranked proposals to stdout and saves top 80 as JSON for
programmatic use by Claude Code sessions.
Usage:
python3 edge-proposer.py
# Then review stdout output, pick good candidates, and create edges via:
# MCP: memory_relate(from_id, to_id, rel_type, description, strength)
# CLI: claude-memory relate <from_id> <to_id> <REL_TYPE> --description "..."
Output:
- Ranked candidates printed to stdout (score, type, titles, shared tags)
- JSON file saved to ~/.claude/tmp/edge-candidates.json
Scoring:
- Minimum threshold: 0.15 (below this, candidates are discarded)
- Importance boost: 1.2x multiplier when avg importance >= 0.7
- Session-log memories tend to produce noise review FOLLOWS edges carefully
Relation types available:
SOLVES, CAUSES, BUILDS_ON, ALTERNATIVE_TO, REQUIRES, FOLLOWS, RELATED_TO
First run: 2026-02-19 produced 5186 candidates from 473 memories,
20 high-quality edges were manually selected and created.
"""
import json
import re
from pathlib import Path
from collections import defaultdict
from itertools import combinations
MEMORY_DIR = Path.home() / ".claude" / "memory"
STATE_FILE = MEMORY_DIR / "_state.json"
GRAPH_DIR = MEMORY_DIR / "graph"
EDGES_DIR = GRAPH_DIR / "edges"
# Type-based heuristics: (type_a, type_b) -> (suggested_rel, direction, base_score)
# direction: "ab" means a->b, "ba" means b->a
TYPE_HEURISTICS = {
("fix", "problem"): ("SOLVES", "ab", 0.6),
("solution", "problem"): ("SOLVES", "ab", 0.7),
("solution", "error"): ("SOLVES", "ab", 0.6),
("fix", "error"): ("SOLVES", "ab", 0.6),
("decision", "solution"): ("BUILDS_ON", "ab", 0.3),
("decision", "decision"): ("ALTERNATIVE_TO", None, 0.2),
("solution", "solution"): ("BUILDS_ON", None, 0.2),
("configuration", "solution"): ("REQUIRES", "ab", 0.3),
("workflow", "configuration"): ("REQUIRES", "ab", 0.3),
("insight", "solution"): ("BUILDS_ON", "ab", 0.4),
("insight", "decision"): ("BUILDS_ON", "ab", 0.4),
("fix", "fix"): ("FOLLOWS", None, 0.15),
("fix", "solution"): ("BUILDS_ON", "ab", 0.2),
("code_pattern", "solution"): ("BUILDS_ON", "ab", 0.3),
("procedure", "workflow"): ("BUILDS_ON", "ab", 0.3),
("configuration", "configuration"): ("RELATED_TO", None, 0.1),
}
def parse_frontmatter(filepath: Path) -> dict | None:
"""Parse YAML frontmatter from a markdown file."""
try:
text = filepath.read_text(encoding="utf-8")
except Exception:
return None
if not text.startswith("---"):
return None
end = text.find("---", 3)
if end == -1:
return None
fm = {}
body = text[end + 3 :].strip()
fm["_body"] = body[:500] # first 500 chars of content for matching
fm["_filepath"] = str(filepath)
for line in text[3:end].strip().splitlines():
if ":" not in line:
continue
key, _, val = line.partition(":")
key = key.strip()
val = val.strip().strip('"').strip("'")
if key == "tags":
# Handle both [a, b] and "a, b" formats
val = val.strip("[]")
fm["tags"] = [
t.strip().strip('"').strip("'") for t in val.split(",") if t.strip()
]
elif key == "importance":
try:
fm["importance"] = float(val)
except ValueError:
pass
else:
fm[key] = val
return fm
def load_memories() -> dict[str, dict]:
"""Load all memories from graph subdirectories."""
memories = {}
type_dirs = [
"solutions",
"fixes",
"decisions",
"configurations",
"problems",
"workflows",
"code-patterns",
"errors",
"general",
"procedures",
"insights",
]
for type_dir in type_dirs:
dirpath = GRAPH_DIR / type_dir
if not dirpath.exists():
continue
for f in dirpath.glob("*.md"):
fm = parse_frontmatter(f)
if fm and "id" in fm:
memories[fm["id"]] = fm
return memories
def load_existing_edges() -> set[tuple[str, str]]:
"""Load existing edges to avoid duplicates."""
existing = set()
if not EDGES_DIR.exists():
return existing
for f in EDGES_DIR.glob("*.md"):
fm = parse_frontmatter(f)
if fm and "from_id" in fm and "to_id" in fm:
existing.add((fm["from_id"], fm["to_id"]))
existing.add((fm["to_id"], fm["from_id"])) # bidirectional check
return existing
def load_decay_state() -> dict[str, float]:
"""Load decay scores from state file."""
if not STATE_FILE.exists():
return {}
try:
state = json.loads(STATE_FILE.read_text())
return {mid: info.get("decay_score", 0) for mid, info in state.items()}
except Exception:
return {}
def tag_overlap_score(tags_a: list[str], tags_b: list[str]) -> float:
"""Jaccard similarity of tag sets."""
if not tags_a or not tags_b:
return 0.0
set_a, set_b = set(tags_a), set(tags_b)
intersection = set_a & set_b
union = set_a | set_b
return len(intersection) / len(union) if union else 0.0
def content_keyword_overlap(body_a: str, body_b: str) -> float:
"""Simple keyword overlap between content bodies."""
if not body_a or not body_b:
return 0.0
def extract_keywords(text: str) -> set[str]:
words = re.findall(r"[a-zA-Z_]{4,}", text.lower())
# Filter common words
stopwords = {
"that",
"this",
"with",
"from",
"have",
"been",
"were",
"they",
"their",
"will",
"would",
"could",
"should",
"which",
"where",
"when",
"what",
"about",
"into",
"also",
"more",
"some",
"then",
"than",
"each",
"only",
"used",
"using",
"after",
"before",
"because",
"between",
"through",
"during",
"added",
"updated",
"fixed",
"error",
"issue",
"problem",
"solution",
"memory",
"memories",
"configuration",
"successfully",
"working",
"works",
}
return {w for w in words if w not in stopwords}
kw_a = extract_keywords(body_a)
kw_b = extract_keywords(body_b)
if not kw_a or not kw_b:
return 0.0
intersection = kw_a & kw_b
union = kw_a | kw_b
return len(intersection) / len(union) if union else 0.0
def get_type_heuristic(
type_a: str, type_b: str
) -> tuple[str, str | None, float] | None:
"""Look up type-based heuristic, checking both orderings."""
key = (type_a, type_b)
if key in TYPE_HEURISTICS:
rel, direction, score = TYPE_HEURISTICS[key]
return rel, direction, score
key_rev = (type_b, type_a)
if key_rev in TYPE_HEURISTICS:
rel, direction, score = TYPE_HEURISTICS[key_rev]
# Flip direction
if direction == "ab":
direction = "ba"
elif direction == "ba":
direction = "ab"
return rel, direction, score
return None
def score_pair(mem_a: dict, mem_b: dict) -> dict | None:
"""Score a candidate edge between two memories."""
tags_a = mem_a.get("tags", [])
tags_b = mem_b.get("tags", [])
# Must share at least one tag
shared_tags = set(tags_a) & set(tags_b)
if not shared_tags:
return None
tag_score = tag_overlap_score(tags_a, tags_b)
content_score = content_keyword_overlap(
mem_a.get("_body", ""), mem_b.get("_body", "")
)
type_a = mem_a.get("type", "general")
type_b = mem_b.get("type", "general")
heuristic = get_type_heuristic(type_a, type_b)
if heuristic:
suggested_rel, direction, type_score = heuristic
else:
suggested_rel = "RELATED_TO"
direction = None
type_score = 0.05
# Composite score
total = (tag_score * 0.4) + (content_score * 0.3) + (type_score * 0.3)
# Boost for high importance memories
imp_a = mem_a.get("importance", 0.5)
imp_b = mem_b.get("importance", 0.5)
if isinstance(imp_a, str):
imp_a = float(imp_a)
if isinstance(imp_b, str):
imp_b = float(imp_b)
avg_importance = (imp_a + imp_b) / 2
if avg_importance >= 0.7:
total *= 1.2
if total < 0.15:
return None
# Determine from/to based on direction
if direction == "ab":
from_mem, to_mem = mem_a, mem_b
elif direction == "ba":
from_mem, to_mem = mem_b, mem_a
else:
# Default: higher importance is "from"
if imp_a >= imp_b:
from_mem, to_mem = mem_a, mem_b
else:
from_mem, to_mem = mem_b, mem_a
return {
"score": round(total, 3),
"rel_type": suggested_rel,
"from_id": from_mem["id"],
"from_title": from_mem.get("title", "?"),
"from_type": from_mem.get("type", "?"),
"to_id": to_mem["id"],
"to_title": to_mem.get("title", "?"),
"to_type": to_mem.get("type", "?"),
"shared_tags": sorted(shared_tags),
"tag_score": round(tag_score, 3),
"content_score": round(content_score, 3),
"type_score": round(type_score, 3),
}
def main():
print("Loading memories...")
memories = load_memories()
print(f" Found {len(memories)} memories")
print("Loading decay state...")
decay_scores = load_decay_state()
# Filter to active + fading only (decay >= 0.2)
active_ids = {
mid for mid, score in decay_scores.items() if score >= 0.2 and mid in memories
}
# Also include memories without decay state (new)
for mid in memories:
if mid not in decay_scores:
active_ids.add(mid)
active_memories = {mid: memories[mid] for mid in active_ids}
print(f" {len(active_memories)} active/fading memories to analyze")
print("Loading existing edges...")
existing = load_existing_edges()
print(f" {len(existing) // 2} existing edges")
print("Scoring candidate pairs...")
candidates = []
# Group by shared tags first to reduce pair space
tag_groups = defaultdict(set)
for mid, mem in active_memories.items():
for tag in mem.get("tags", []):
tag_groups[tag].add(mid)
# Collect unique pairs that share at least one tag
seen_pairs = set()
for tag, mids in tag_groups.items():
if len(mids) < 2 or len(mids) > 50: # skip too-common tags
continue
for a, b in combinations(mids, 2):
pair = tuple(sorted([a, b]))
if pair in seen_pairs:
continue
if (a, b) in existing or (b, a) in existing:
continue
seen_pairs.add(pair)
result = score_pair(active_memories[a], active_memories[b])
if result:
candidates.append(result)
# Sort by score descending
candidates.sort(key=lambda x: x["score"], reverse=True)
print(f"\n{'='*100}")
print(f"TOP EDGE CANDIDATES ({len(candidates)} total, showing top 80)")
print(f"{'='*100}\n")
for i, c in enumerate(candidates[:80], 1):
print(f"#{i:3d} | Score: {c['score']:.3f} | {c['rel_type']}")
print(f" FROM [{c['from_type']}] {c['from_title']}")
print(f" TO [{c['to_type']}] {c['to_title']}")
print(
f" Tags: {', '.join(c['shared_tags'])} | "
f"tag={c['tag_score']:.2f} content={c['content_score']:.2f} type={c['type_score']:.2f}"
)
print(f" IDs: {c['from_id']} -> {c['to_id']}")
print()
# Also output as JSON for programmatic use
json_path = MEMORY_DIR.parent / "tmp" / "edge-candidates.json"
json_path.parent.mkdir(parents=True, exist_ok=True)
json_path.write_text(json.dumps(candidates[:80], indent=2))
print(f"Full candidates saved to: {json_path}")
if __name__ == "__main__":
main()

1653
tmp/edge-candidates.json Normal file

File diff suppressed because it is too large Load Diff