
Memory
- 19 installs
- 213 repo stars
- Updated August 4, 2026
- yonatangross/skillforge-claude-plugin
Helps with ai & agent building tasks.
About
memory is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- memory
- AI & Agent Building
- AI-coding skill
Memory by the numbers
- 19 all-time installs (skills.sh)
- Ranked #10,587 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/yonatangross/skillforge-claude-plugin --skill memoryAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 19 |
|---|---|
| repo stars | ★ 213 |
| Last updated | August 4, 2026 |
| Repository | yonatangross/skillforge-claude-plugin ↗ |
What it does
Helps with ai & agent building tasks.
Files
Memory - Read & Access Operations
Unified read-side memory skill with subcommands for searching, loading, syncing, history, and visualization.
Cross-session read strategy (Opus 4.8 / CC 2.1.111+): Opus 4.8 reads filesystem memory more reliably than older tiers. When loading context at session start, prefer the layered read order:
1. ~/.claude/projects/<slug>/memory/MEMORY.md (durable index — load first, always)2..claude/chain/state.json+ most recentNN-*.jsonhandoff (session continuation)
3. MCP mcp__memory__search_nodes for anything the filesystem index doesn't answer (typed graph traversal)>
Layer 1 is cheap (small index file), Layer 2 is scoped (session-specific), Layer 3 is selective (only when needed). Avoid dumping the full knowledge graph into context — use the index to narrow the search first.
Argument Resolution
SUBCOMMAND = "$ARGUMENTS[0]" # First token: search, load, history, viz, status
QUERY = "$ARGUMENTS[1]" # Second token onward: search query or flags
# $ARGUMENTS is the full string (CC 2.1.59 indexed access)Usage
/ork:memory search <query> # Search knowledge graph
/ork:memory load # Load context at session start
/ork:memory history # View decision timeline
/ork:memory viz # Visualize knowledge graph
/ork:memory status # Show memory system health---
CRITICAL: Use AskUserQuestion When No Subcommand
If invoked without a subcommand, ask the user what they want:
AskUserQuestion(
questions=[{
"question": "What memory operation do you need?",
"header": "Operation",
"options": [
{"label": "search", "description": "Search decisions and patterns in knowledge graph"},
{"label": "load", "description": "Load relevant context for this session"},
{"label": "history", "description": "Decision timeline + knowledge-graph viz (--mermaid)"},
{"label": "status", "description": "Check memory system health"}
],
"multiSelect": false
}]
)---
Subcommands
Load details: Read("${CLAUDE_SKILL_DIR}/references/memory-commands.md") for full usage, flags, output formats, and context-aware result limits for each subcommand.
| Subcommand | Purpose |
|---|---|
search | Search past decisions, patterns, entities. Supports --category (maps to metadata.category), --limit, --agent (scopes by agent_id), --global filter flags |
load | Auto-load relevant memories at session start. Supports --project, --global |
history | Decision timeline with table, Mermaid, or JSON output. Supports --since, --mermaid |
viz | Render knowledge graph as Mermaid diagram. See also Read("${CLAUDE_SKILL_DIR}/references/mermaid-patterns.md") |
status | Memory system health check |
---
Scheduled Sweeps (cron)
Nightly KG staleness report
Cron job in .github/workflows/memory-staleness.yml runs nightly at 04:00 UTC. Calls scripts/staleness_cron.py to scan ALL memory MCP entities, flag those whose latest observation timestamp is older than 30 days, and write a Markdown report to docs/reports/memory-staleness-YYYY-MM-DD.md.
python3 ${CLAUDE_SKILL_DIR}/scripts/staleness_cron.py docs/reports/ \
--threshold-days 30 --limit 50Auto-skip conditions (all exit 0, all WARN-logged):
| Skip reason | Trigger |
|---|---|
yg-mcp-core not importable | yg-mcp-core>=0.3.0 not installed (orchestkit is public; yg-mcp-core lives on private pypi.yonyon.ai — HQ-only) |
memory MCP unreachable | memory MCP server down OR .mcp.json doesn't define memory |
Report contents:
- Total / stale / fresh entity counts
- Top N stale entries sorted by staleness (no-timestamp first, then oldest → newest)
- Each row: name, entityType, age in days, observations count
- Suggested actions per age bucket (>90d review, >180d archive candidate)
Treats entities with no parsable timestamp as stale — operator intervention is the right outcome (backfill last_read observation OR prune).
Pure helpers (parse_iso_timestamp, latest_observation_timestamp, is_stale, build_report_payload, render_markdown) live in sibling staleness_lib.py for unit-testability.
Mirrors Yonatan-HQ/hq-ext-plugin#194 (audio_podcast) and orchestkit#1886 (post-synth podcast) + #1887 (memory writeback) pattern. Unblocked by Yonatan-HQ/core#993 (yg-mcp-core 0.3.0).
---
Workflow
1. Parse Subcommand
Extract first argument as subcommand
If no subcommand -> AskUserQuestion
Validate subcommand is one of: search, load, history, viz, status
Parse remaining flags
Check for --agent <agent-id> flag → agent_id: "ork:{agent-id}"2. Execute Subcommand
Route to appropriate handler based on subcommand.
3. Report Results
Format output appropriate to the operation.
---
Rules Quick Reference
| Rule | Impact | What It Covers |
|---|---|---|
entity-extraction-patterns (load ${CLAUDE_SKILL_DIR}/rules/entity-extraction-patterns.md) | HIGH | Entity types, relation types, graph query semantics |
deduplication-strategy (load ${CLAUDE_SKILL_DIR}/rules/deduplication-strategy.md) | HIGH | Edit-over-Write pattern, anchor-based insertion, verification |
---
Session Resume
Load details: Read("${CLAUDE_SKILL_DIR}/references/session-resume-patterns.md") for CC 2.1.31 resume hints, context capture before ending, and resume workflows for PRs, issues, and implementations.
---
Related Skills
ork:remember- Store decisions and patterns (write-side)
---
Error Handling
- If graph empty for viz: Show helpful message about using /ork:remember
- If subcommand invalid: Show usage help
- If memory files corrupt: Report and offer repair
- If search query empty: Show recent entities instead
- If no search results: Suggest alternatives
Memory Subcommand Reference
Complete usage, flags, and output format details for each /ork:memory subcommand.
search - Search Knowledge Graph
Search past decisions, patterns, and entities from the knowledge graph.
Usage:
/ork:memory search <query> # Search knowledge graph
/ork:memory search --category <cat> <query> # Filter by category
/ork:memory search --limit <n> <query> # Limit results (default: 10)
/ork:memory search --agent <agent-id> <query> # Filter by agent scope
/ork:memory search --global <query> # Search cross-project best practicesFlags:
| Flag | Behavior |
|---|---|
| (default) | Search graph |
--limit <n> | Max results (default: 10) |
--category <cat> | Filter by category |
--agent <agent-id> | Filter results to a specific agent's memories |
--global | Search cross-project best practices |
Context-Aware Result Limits:
Result limits automatically adjust based on context_window.used_percentage:
| Context Usage | Default Limit | Behavior |
|---|---|---|
| 0-70% | 10 results | Full results with details |
| 70-85% | 5 results | Reduced, summarized results |
| >85% | 3 results | Minimal with "more available" hint |
Search Workflow:
1. Parse flags (--category, --limit, --agent, --global) 2. Build filters from flags:
Check for --category <cat> flag -> metadata.category: "<cat>"
Check for --agent <agent-id> flag -> agent_id: "ork:{agent-id}"
Check for --global flag -> user_id: "orchestkit-global-best-practices"3. Search knowledge graph via mcp__memory__search_nodes:
{ "query": "user's search query" }Entity Types to Look For:
Technology: Tools, frameworks, databases (pgvector, PostgreSQL, React)Agent: OrchestKit agents (database-engineer, backend-system-architect)Pattern: Named patterns (cursor-pagination, connection-pooling)Decision: Architectural decisionsProject: Project-specific contextAntiPattern: Failed patterns
Result Formats:
Found {count} results matching "{query}":
[GRAPH] {entity_name} ({entity_type})
-> {relation1} -> {target1}
Observations: {observation1}, {observation2}No results:
No results found matching "{query}"
Try:
- Broader search terms
- /ork:remember to store new decisions
- --global flag to search cross-project best practices---
load - Load Session Context
Auto-load relevant memories at session start from knowledge graph.
Usage:
/ork:memory load # Load all relevant context
/ork:memory load --project # Project-specific only
/ork:memory load --global # Include global best practicesWhat it loads: 1. Recent decisions from .claude/memory/decisions.jsonl 2. Active project context 3. Agent-specific memories (if in agent context) 4. Global best practices (if --global)
---
history - Decision Timeline
Visualize architecture decisions over time, tracking evolution and rationale.
Usage:
/ork:memory history # Show recent decisions
/ork:memory history --category <cat> # Filter by category
/ork:memory history --since 7d # Last 7 days
/ork:memory history --mermaid # Output as Mermaid timelineOutput formats:
- Table view (default)
- Mermaid timeline diagram (--mermaid)
- JSON (--json)
---
viz - Knowledge Graph Visualization
Render the local knowledge graph as a Mermaid diagram. See mermaid-patterns.md for complete rendering reference.
Usage:
/ork:memory viz # Full graph
/ork:memory viz --entity <name> # Focus on specific entity
/ork:memory viz --depth 2 # Limit relationship depth
/ork:memory viz --type <type> # Filter by entity typeEntity types:
- Technology, Agent, Pattern, Decision, Project, AntiPattern, Constraint, Preference
Relation types:
- USES, RECOMMENDS, REQUIRES, ENABLES, PREFERS, CHOSE_OVER, USED_FOR, CONFLICTS_WITH
---
status - Memory Health Check
Show memory system status and health.
Usage:
/ork:memory statusOutput:
Memory System Status:
Graph Memory: healthy (42 decisions, 0 corrupt)
Queue Depth: 3 pendingMermaid Diagram Patterns for Graph Visualization
Complete reference for the OrchestKit visualization system (GH #246).
Color Scheme - 8 Entity Types
| Entity Type | Fill | Stroke | Hex Fill | Hex Stroke |
|---|---|---|---|---|
| Decision | Blue | Dark Blue | #3B82F6 | #1E40AF |
| Preference | Green | Dark Green | #10B981 | #047857 |
| Problem | Red | Dark Red | #EF4444 | #B91C1C |
| Solution | Bright Green | Forest Green | #22C55E | #15803D |
| Technology | Orange | Dark Orange | #F59E0B | #B45309 |
| Pattern | Purple | Dark Purple | #8B5CF6 | #5B21B6 |
| Tool | Cyan | Dark Cyan | #06B6D4 | #0E7490 |
| Workflow | Pink | Dark Pink | #EC4899 | #BE185D |
Class Definitions (copy-paste ready)
classDef decision fill:#3B82F6,stroke:#1E40AF,color:#fff
classDef preference fill:#10B981,stroke:#047857,color:#fff
classDef problem fill:#EF4444,stroke:#B91C1C,color:#fff
classDef solution fill:#22C55E,stroke:#15803D,color:#fff
classDef tech fill:#F59E0B,stroke:#B45309,color:#fff
classDef pattern fill:#8B5CF6,stroke:#5B21B6,color:#fff
classDef tool fill:#06B6D4,stroke:#0E7490,color:#fff
classDef workflow fill:#EC4899,stroke:#BE185D,color:#fffEdge Styles - 8 Relation Types
| Relation | Mermaid Syntax | Color Intent | Semantic |
|---|---|---|---|
| CHOSE | A -->│CHOSE│ B | Solid (strong positive) | Selected this option |
| CHOSE_OVER | A -..->│CHOSE_OVER│ B | Dashed (rejected) | Rejected alternative |
| MENTIONS | A -.->│MENTIONS│ B | Dotted (weak) | Passive reference |
| CONSTRAINT | A -->│CONSTRAINT│ B | Solid (limiting) | Limits or restricts |
| TRADEOFF | A -..->│TRADEOFF│ B | Dashed (cost) | Acknowledged cost |
| RELATES_TO | A -.->│RELATES_TO│ B | Dotted (general) | General association |
| SOLVED_BY | A -->│SOLVED_BY│ B | Solid (resolution) | Problem resolved |
| PREFERS | A -->│PREFERS│ B | Solid (preference) | Stated preference |
Edge Categorization
Strong edges (solid arrows -->):
- CHOSE, SOLVED_BY, PREFERS, CONSTRAINT
Rejected/cost edges (long dashes -..->):
- CHOSE_OVER, TRADEOFF
Weak edges (dotted -.->)
- MENTIONS, RELATES_TO
Node ID Prefixes
| Type | Prefix | Example ID | Label |
|---|---|---|---|
| Decision | d_ | d_use_postgresql | "Use PostgreSQL" |
| Preference | pref_ | pref_func_components | "Functional components" |
| Problem | prob_ | prob_n1_query | "N+1 query problem" |
| Solution | sol_ | sol_dataloader | "DataLoader batching" |
| Technology | t_ | t_postgresql | "PostgreSQL" |
| Pattern | p_ | p_cqrs | "CQRS" |
| Tool | tool_ | tool_eslint | "ESLint" |
| Workflow | w_ | w_deploy_pipeline | "Deploy pipeline" |
Node ID Sanitization
Convert entity names to valid Mermaid IDs:
"PostgreSQL" -> t_postgresql
"cursor-pagination" -> p_cursor_pagination
"Use PostgreSQL" -> d_use_postgresql
"FastAPI v2.0" -> t_fastapi_v2_0
"N+1 query problem" -> prob_n1_query_problem
"DataLoader batching" -> sol_dataloader_batching
"Deploy pipeline v3" -> w_deploy_pipeline_v3Rules:
- Lowercase the name
- Replace spaces, hyphens, dots with underscores
- Remove special characters except
[a-z0-9_] - Prefix with type abbreviation
- Use quoted labels:
id["Human Readable Label"] - Keep labels under 40 characters for readability
Small Graph Example (< 10 entities)
graph TD
classDef decision fill:#3B82F6,stroke:#1E40AF,color:#fff
classDef tech fill:#F59E0B,stroke:#B45309,color:#fff
classDef pattern fill:#10B981,stroke:#047857,color:#fff
d1["Use PostgreSQL"]:::decision
t1["PostgreSQL"]:::tech
t2["MongoDB"]:::tech
p1["cursor-pagination"]:::pattern
d1 -->|CHOSE| t1
d1 -..->|CHOSE_OVER| t2
t1 -.->|RELATES_TO| p1Medium Graph Example (10-30 entities)
Use subgraphs for organization:
graph TD
classDef decision fill:#3B82F6,stroke:#1E40AF,color:#fff
classDef preference fill:#10B981,stroke:#047857,color:#fff
classDef problem fill:#EF4444,stroke:#B91C1C,color:#fff
classDef solution fill:#22C55E,stroke:#15803D,color:#fff
classDef tech fill:#F59E0B,stroke:#B45309,color:#fff
classDef pattern fill:#8B5CF6,stroke:#5B21B6,color:#fff
classDef tool fill:#06B6D4,stroke:#0E7490,color:#fff
subgraph Decisions
d1["Use PostgreSQL for DB"]:::decision
d2["Implement CQRS pattern"]:::decision
d3["Choose FastAPI framework"]:::decision
end
subgraph Preferences
pref1["Prefer TypeScript strict"]:::preference
end
subgraph Problems
prob1["N+1 query in user list"]:::problem
end
subgraph Solutions
sol1["DataLoader batching"]:::solution
end
subgraph Technologies
t1["PostgreSQL"]:::tech
t2["FastAPI"]:::tech
t3["Redis"]:::tech
t4["MongoDB"]:::tech
end
subgraph Patterns
p1["CQRS"]:::pattern
p2["Event Sourcing"]:::pattern
p3["cursor-pagination"]:::pattern
end
subgraph Tools
tool1["ESLint"]:::tool
tool2["Docker"]:::tool
end
d1 -->|CHOSE| t1
d1 -..->|CHOSE_OVER| t4
d2 -->|CHOSE| p1
d3 -->|CHOSE| t2
pref1 -->|PREFERS| tool1
prob1 -->|SOLVED_BY| sol1
prob1 -->|CONSTRAINT| t1
p1 -.->|RELATES_TO| p2
t1 -.->|RELATES_TO| p3
t2 -.->|RELATES_TO| t3
sol1 -.->|MENTIONS| t3
d2 -..->|TRADEOFF| p2Large Graph Example (30+ entities)
For large graphs, truncate to most-connected entities:
1. Count connections per entity (in-degree + out-degree) 2. Keep top N entities by connection count (default: 50) 3. Switch to graph LR for wide graphs 4. Add note: "Showing 50 of 120 entities (most connected)"
graph LR
classDef decision fill:#3B82F6,stroke:#1E40AF,color:#fff
classDef tech fill:#F59E0B,stroke:#B45309,color:#fff
classDef pattern fill:#8B5CF6,stroke:#5B21B6,color:#fff
note["Showing 15 of 85 entities (most connected)"]
subgraph Core Decisions
d1["Use PostgreSQL"]:::decision
d2["Adopt CQRS"]:::decision
d3["Choose FastAPI"]:::decision
end
subgraph Key Technologies
t1["PostgreSQL"]:::tech
t2["FastAPI"]:::tech
t3["Redis"]:::tech
t4["Kafka"]:::tech
end
subgraph Key Patterns
p1["CQRS"]:::pattern
p2["Event Sourcing"]:::pattern
end
d1 -->|CHOSE| t1
d2 -->|CHOSE| p1
d3 -->|CHOSE| t2
p1 -.->|RELATES_TO| p2
t2 -.->|RELATES_TO| t3
t3 -.->|RELATES_TO| t4For very large graphs (100+ relations), collapse weak relations:
Hiding 45 weak relations (MENTIONS, RELATES_TO). Use --relation all to show all edges.Complete Template
Full copy-paste template for generating a graph visualization:
graph TD
%% === Entity Type Styles (8 types) ===
classDef decision fill:#3B82F6,stroke:#1E40AF,color:#fff
classDef preference fill:#10B981,stroke:#047857,color:#fff
classDef problem fill:#EF4444,stroke:#B91C1C,color:#fff
classDef solution fill:#22C55E,stroke:#15803D,color:#fff
classDef tech fill:#F59E0B,stroke:#B45309,color:#fff
classDef pattern fill:#8B5CF6,stroke:#5B21B6,color:#fff
classDef tool fill:#06B6D4,stroke:#0E7490,color:#fff
classDef workflow fill:#EC4899,stroke:#BE185D,color:#fff
%% === Subgraphs by Entity Type ===
subgraph Decisions
%% d_<id>["Label"]:::decision
end
subgraph Preferences
%% pref_<id>["Label"]:::preference
end
subgraph Problems
%% prob_<id>["Label"]:::problem
end
subgraph Solutions
%% sol_<id>["Label"]:::solution
end
subgraph Technologies
%% t_<id>["Label"]:::tech
end
subgraph Patterns
%% p_<id>["Label"]:::pattern
end
subgraph Tools
%% tool_<id>["Label"]:::tool
end
subgraph Workflows
%% w_<id>["Label"]:::workflow
end
%% === Relations (8 types) ===
%% Strong: CHOSE, SOLVED_BY, PREFERS, CONSTRAINT
%% src -->|CHOSE| dst
%% src -->|SOLVED_BY| dst
%% src -->|PREFERS| dst
%% src -->|CONSTRAINT| dst
%% Rejected/Cost: CHOSE_OVER, TRADEOFF
%% src -..->|CHOSE_OVER| dst
%% src -..->|TRADEOFF| dst
%% Weak: MENTIONS, RELATES_TO
%% src -.->|MENTIONS| dst
%% src -.->|RELATES_TO| dstRendering Notes
- Mermaid diagrams render in GitHub, VS Code, and most Markdown viewers
- Keep node labels under 40 characters for readability
- Use
graph TD(top-bottom) for hierarchical graphs - Use
graph LR(left-right) for sequential/timeline or very wide graphs - Empty subgraphs should be omitted from output
- Self-referential edges (entity -> itself) should be excluded
CC 2.1.31 Session Resume Hints
At session end, Claude shows resume hints. To maximize resume effectiveness:
Capture Context Before Ending
# Store key decisions and context
/ork:remember Key decisions for next session:
- Decision 1: [brief]
- Decision 2: [brief]
- Next steps: [what remains]Resume Patterns
# For PR work: Use --from-pr (CC 2.1.27)
/ork:create-pr
# Later: claude --from-pr 123
# For issue fixing: Use memory load
/ork:fix-issue 456
# Later: /ork:memory load # Reloads investigation context
# For implementation: Use memory search
/ork:implement user-auth
# Later: /ork:memory search "user-auth implementation"Best Practice
Always store investigation findings before session end:
/ork:remember Session summary for {task}:
Completed: [what was done]
Findings: [key discoveries]
Next steps: [what remains]
Blockers: [if any]Memory Rules Index
| Rule | Impact | What It Covers |
|---|---|---|
| entity-extraction-patterns | HIGH | Entity types, relation types, graph query semantics |
| deduplication-strategy | HIGH | Edit-over-Write pattern, anchor-based insertion, verification |
Deduplication Strategy
When loading or searching memories, prevent duplicate context injection.
Rules
1. Edit over Write -- When updating .claude/memory/MEMORY.md or project memory files, prefer Edit over Write to preserve existing content and avoid accidental overwrites.
2. Anchor-based insertion -- Always verify the target section header exists before inserting:
## Recent Decisions## Patterns## Preferences## Detailed Notes
3. Surgical edits -- Use Edit(file_path, old_string=anchor_line, new_string=anchor_line + "\n" + new_content) to append under a section header without overwriting the rest.
4. Verify after edit -- Always Read(file_path) after editing to confirm the edit applied correctly.
Incorrect:
# Overwrite entire file — loses existing memories
Write(file_path=".claude/memory/MEMORY.md", content="## New Decision\n- Use Redis")Correct:
# Surgical edit — append under existing section header
Edit(file_path=".claude/memory/MEMORY.md",
old_string="## Recent Decisions",
new_string="## Recent Decisions\n- Use Redis for session caching (2026-03-01)")Why Edit Over Write
| Approach | Risk | Permission |
|---|---|---|
| Write (overwrite) | Loses existing content if template incomplete | Requires approval |
| Edit (surgical) | Only modifies target section | Often auto-approved |
Hook Exception
The memory-writer.ts hook uses Node.js writeFileSync -- this is correct for hooks context where full file control is needed. The Edit pattern above is for agent-side SKILL.md operations.
Entity Extraction Patterns
When searching or visualizing the knowledge graph, recognize these entity types and their typical observations.
Entity Types
| Type | Examples | Typical Observations |
|---|---|---|
Technology | pgvector, PostgreSQL, React | Version, use case, project association |
Agent | database-engineer, backend-system-architect | Capabilities, scope, assigned tasks |
Pattern | cursor-pagination, connection-pooling | When to use, trade-offs, implementation notes |
Decision | "Use PostgreSQL for DB" | Rationale, alternatives considered, date |
Project | Project-specific context | Stack, status, team, constraints |
AntiPattern | Failed or abandoned patterns | Why it failed, what replaced it |
Constraint | Budget, timeline, compliance | Source, severity, workarounds |
Preference | "Prefer TypeScript strict" | Strength, scope, exceptions |
Incorrect:
# Vague entity type, no useful observations
create_entities([{"name": "thing", "entityType": "misc", "observations": ["used it"]}])Correct:
# Specific type with actionable observations
create_entities([{"name": "pgvector", "entityType": "Technology",
"observations": ["v0.7.0", "Used for RAG embeddings in acme-app", "Requires PostgreSQL 15+"]}])Relation Types
| Relation | Semantic | Arrow Style |
|---|---|---|
| USES | Active dependency | Solid |
| RECOMMENDS | Suggested approach | Solid |
| REQUIRES | Hard dependency | Solid |
| ENABLES | Unlocks capability | Solid |
| PREFERS | Stated preference | Solid |
| CHOSE_OVER | Rejected alternative | Dashed |
| USED_FOR | Purpose link | Solid |
| CONFLICTS_WITH | Incompatibility | Dashed |
/**
* graph-utils.mjs - Shared utilities for OrchestKit graph visualization scripts
*
* Pure library module: no main(), no side effects on import.
* Used by render-graph.mjs and render-playground.mjs.
*/
import { readFileSync, existsSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
// ---------------------------------------------------------------------------
// Entity type configuration (single source of truth)
// ---------------------------------------------------------------------------
export const ENTITY_TYPES = {
Decision: { prefix: 'd_', className: 'decision', color: '#3B82F6' },
Preference: { prefix: 'pref_', className: 'preference', color: '#10B981' },
Problem: { prefix: 'prob_', className: 'problem', color: '#EF4444' },
Solution: { prefix: 'sol_', className: 'solution', color: '#22C55E' },
Technology: { prefix: 't_', className: 'tech', color: '#F59E0B' },
Pattern: { prefix: 'p_', className: 'pattern', color: '#8B5CF6' },
Tool: { prefix: 'tool_', className: 'tool', color: '#06B6D4' },
Workflow: { prefix: 'w_', className: 'workflow', color: '#EC4899' },
};
// ---------------------------------------------------------------------------
// Relation classification sets
// ---------------------------------------------------------------------------
export const STRONG_RELATIONS = new Set([
'CHOSE', 'SOLVED_BY', 'PREFERS', 'CONSTRAINT', 'USES', 'USED_FOR',
]);
export const REJECTED_RELATIONS = new Set([
'CHOSE_OVER', 'TRADEOFF',
]);
// ---------------------------------------------------------------------------
// Keyword lists for entity type inference
// ---------------------------------------------------------------------------
export const TECH_KEYWORDS = [
'postgresql', 'postgres', 'redis', 'mongodb', 'mysql', 'fastapi', 'react',
'vue', 'angular', 'next', 'node', 'python', 'typescript', 'javascript',
'kafka', 'rabbitmq', 'graphql', 'rest', 'grpc', 'esm', 'babel', 'esbuild',
'rollup', 'webpack', 'vite', 'vitest', 'jest', 'pgvector',
];
export const TOOL_KEYWORDS = [
'eslint', 'prettier', 'docker', 'k6', 'git', 'github', 'ci', 'cd', 'npm',
'biome', 'playwright', 'cypress',
];
// ---------------------------------------------------------------------------
// Pure functions
// ---------------------------------------------------------------------------
/**
* Sanitize a name into a safe lowercase identifier.
* @param {string} name
* @returns {string}
*/
export function sanitizeId(name) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
}
/**
* Build a node ID from entity type and name.
* Falls back to Pattern prefix for unknown types.
* @param {string} entityType
* @param {string} name
* @returns {string}
*/
export function makeNodeId(entityType, name) {
const cfg = ENTITY_TYPES[entityType] || ENTITY_TYPES.Pattern;
return cfg.prefix + sanitizeId(name);
}
/**
* Infer entity type from a name string using keyword matching.
* @param {string} name
* @returns {string}
*/
export function inferEntityType(name) {
const lower = name.toLowerCase();
if (TECH_KEYWORDS.some(k => lower.includes(k))) return 'Technology';
if (TOOL_KEYWORDS.some(k => lower.includes(k))) return 'Tool';
return 'Pattern';
}
/**
* Normalize a raw entity (string or object) into a standard shape.
* @param {string|object} raw
* @returns {{ name: string, entityType: string, observations: string[] }}
*/
export function normalizeEntity(raw) {
if (typeof raw === 'string') {
return { name: raw, entityType: inferEntityType(raw), observations: [] };
}
return {
name: raw.name || String(raw),
entityType: raw.entityType || 'Pattern',
observations: raw.observations || [],
};
}
/**
* Normalize a raw relation into a standard shape.
* @param {object} rel
* @returns {{ from: string, to: string, relationType: string }}
*/
export function normalizeRelation(rel) {
return {
from: rel.from,
to: rel.to,
relationType: rel.relationType || rel.type || 'RELATES_TO',
};
}
/**
* Classify an edge by its relation type.
* @param {string} relationType
* @returns {'strong'|'rejected'|'weak'}
*/
export function classifyEdge(relationType) {
if (REJECTED_RELATIONS.has(relationType)) return 'rejected';
if (STRONG_RELATIONS.has(relationType)) return 'strong';
return 'weak';
}
/**
* Build a lookup map from entity name to pre-computed node ID.
* @param {Map<string, object>} entityMap - Map of name -> { entityType, ... }
* @returns {Object<string, string>} - { name: nodeId }
*/
export function buildEntityNodeIdMap(entityMap) {
const result = {};
for (const [name, ent] of entityMap) {
const entityType = ent.entityType || 'Pattern';
result[name] = makeNodeId(entityType, name);
}
return result;
}
// ---------------------------------------------------------------------------
// I/O helpers
// ---------------------------------------------------------------------------
/**
* Get the project directory from env or cwd.
* @returns {string}
*/
export function getProjectDir() {
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
}
/**
* Read a JSONL file, skipping corrupt lines.
* @param {string} filePath
* @returns {object[]}
*/
export function readJsonl(filePath) {
if (!existsSync(filePath)) return [];
const lines = readFileSync(filePath, 'utf-8').split('\n').filter(Boolean);
const records = [];
let skipped = 0;
for (const line of lines) {
try {
records.push(JSON.parse(line));
} catch {
skipped++;
}
}
if (skipped > 0) console.warn(`Skipped ${skipped} corrupt record(s)`);
return records;
}
/**
* Open a file in the platform's default browser.
* @param {string} filePath
*/
export function openInBrowser(filePath) {
try {
const platform = process.platform;
const cmd = platform === 'darwin' ? 'open' : platform === 'win32' ? 'start' : 'xdg-open';
execFileSync(cmd, [filePath], { stdio: 'ignore' });
console.log('Opened in browser.');
} catch {
console.log(`Open manually: ${filePath}`);
}
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' https://unpkg.com; style-src 'unsafe-inline'; img-src data:; connect-src 'none'">
<title>OrchestKit Knowledge Graph Playground</title>
<script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
<style>
/* ===== CSS Custom Properties ===== */
:root {
--color-bg-base: #0f172a;
--color-bg-surface: #1e293b;
--color-bg-elevated: #162032;
--color-border-default: #334155;
--color-border-subtle: #1e293b;
--color-border-input: #475569;
--color-text-primary: #f8fafc;
--color-text-secondary: #e2e8f0;
--color-text-body: #cbd5e1;
--color-text-muted: #94a3b8;
--color-text-dim: #64748b;
--color-interactive: #3B82F6;
--color-interactive-hover: #2563EB;
--color-scrollbar-track: #0f172a;
--color-scrollbar-thumb: #334155;
--color-scrollbar-hover: #475569;
}
/* ===== Reset & Base ===== */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
height: 100%;
}
body {
background: var(--color-bg-base);
color: var(--color-text-secondary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
flex-direction: column;
}
/* ===== Focus Styles (Accessibility) ===== */
:focus-visible {
outline: 2px solid var(--color-interactive);
outline-offset: 2px;
}
/* ===== Skip Link ===== */
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: var(--color-interactive);
color: #fff;
padding: 0.5rem 1rem;
z-index: 200;
font-size: 0.875rem;
border-radius: 0 0 0.25rem 0;
}
.skip-link:focus { top: 0; }
/* Screen reader only */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* ===== Top Bar ===== */
.top-bar {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1.25rem;
background: var(--color-bg-surface);
border-bottom: 1px solid var(--color-border-default);
flex-wrap: wrap;
}
.top-bar h1 {
font-size: 1rem;
font-weight: 600;
color: var(--color-text-primary);
white-space: nowrap;
}
.top-bar select,
.top-bar input[type="text"] {
background: var(--color-bg-base);
color: var(--color-text-secondary);
border: 1px solid var(--color-border-input);
border-radius: 0.375rem;
padding: 0.375rem 0.625rem;
font-size: 0.8125rem;
font-family: inherit;
outline: none;
}
.top-bar select:focus,
.top-bar input:focus {
border-color: var(--color-interactive);
}
.top-bar input[type="text"] {
width: 180px;
}
/* ===== Tabs ===== */
.tab-bar {
display: flex;
background: var(--color-bg-surface);
border-bottom: 1px solid var(--color-border-default);
padding: 0 1.25rem;
gap: 0;
}
.tab-btn {
padding: 0.625rem 1.25rem;
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.8125rem;
font-family: inherit;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.15s;
}
.tab-btn:hover { color: var(--color-text-secondary); }
.tab-btn.active {
color: var(--color-interactive);
border-bottom-color: var(--color-interactive);
}
/* ===== Tab Content ===== */
.tab-content {
display: none;
flex: 1;
overflow: auto;
}
.tab-content.active {
display: flex;
flex-direction: column;
}
/* ===== Graph Tab ===== */
.graph-toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 1.25rem;
background: var(--color-bg-elevated);
border-bottom: 1px solid var(--color-border-subtle);
flex-wrap: wrap;
}
.entity-toggle {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
cursor: pointer;
user-select: none;
}
/* Visually hidden but accessible (not display:none) */
.entity-toggle input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.entity-toggle .dot {
width: 10px;
height: 10px;
border-radius: 2px;
opacity: 0.4;
transition: opacity 0.15s;
}
.entity-toggle input:checked + .dot {
opacity: 1;
}
#graph-canvas {
flex: 1;
min-height: 0;
height: calc(100vh - 200px);
background: var(--color-bg-base);
}
.graph-statusbar {
display: flex;
gap: 1.5rem;
padding: 0.375rem 1.25rem;
background: var(--color-bg-surface);
border-top: 1px solid var(--color-border-default);
font-size: 0.75rem;
color: var(--color-text-muted);
}
/* ===== Detail Panel (slide-in) ===== */
.detail-panel {
position: fixed;
top: 0;
right: -100%;
width: min(400px, 100vw);
max-width: 100vw;
height: 100vh;
background: var(--color-bg-surface);
border-left: 1px solid var(--color-border-default);
z-index: 100;
transition: right 0.25s ease;
overflow-y: auto;
padding: 1.25rem;
}
.detail-panel.open { right: 0; }
.detail-panel .close-btn {
position: absolute;
top: 0.75rem;
right: 0.75rem;
background: none;
border: none;
color: var(--color-text-muted);
font-size: 1.25rem;
cursor: pointer;
}
.detail-panel h2 {
font-size: 1.125rem;
color: var(--color-text-primary);
margin-bottom: 0.5rem;
padding-right: 2rem;
}
.detail-panel .type-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
color: #fff;
margin-bottom: 0.75rem;
}
.detail-section {
margin-bottom: 1rem;
}
.detail-section h3 {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
margin-bottom: 0.375rem;
}
.detail-section ul {
list-style: none;
font-size: 0.8125rem;
}
.detail-section li {
padding: 0.25rem 0;
border-bottom: 1px solid var(--color-bg-base);
color: var(--color-text-body);
}
.detail-section li:last-child { border-bottom: none; }
/* ===== Decisions Tab ===== */
.decisions-toolbar {
display: flex;
gap: 0.75rem;
padding: 0.75rem 1.25rem;
background: var(--color-bg-elevated);
border-bottom: 1px solid var(--color-border-subtle);
flex-wrap: wrap;
}
.decision-list {
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
overflow-y: auto;
}
.decision-card {
background: var(--color-bg-surface);
border: 1px solid var(--color-border-default);
border-radius: 0.5rem;
padding: 1rem;
}
.decision-card .card-header {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.5rem;
}
.decision-card .card-header .type-badge {
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
color: #fff;
}
.decision-card .card-header .what {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-primary);
flex: 1;
}
.decision-card .card-header .timestamp {
font-size: 0.6875rem;
color: var(--color-text-muted);
}
.confidence-bar {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
color: var(--color-text-muted);
}
.confidence-bar .bar {
width: 40px;
height: 4px;
background: var(--color-border-default);
border-radius: 2px;
overflow: hidden;
}
.confidence-bar .bar .fill {
height: 100%;
border-radius: 2px;
background: var(--color-interactive);
}
.decision-card .why {
font-size: 0.8125rem;
color: var(--color-text-body);
margin-bottom: 0.5rem;
line-height: 1.5;
}
.decision-card .alternatives {
font-size: 0.75rem;
color: var(--color-text-muted);
margin-bottom: 0.5rem;
}
.entity-chips {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-bottom: 0.375rem;
}
.entity-chip {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.6875rem;
color: #fff;
cursor: pointer;
}
.entity-chip:hover { opacity: 0.8; }
.relation-list {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.relation-list span { color: var(--color-text-secondary); }
.decision-card .card-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--color-bg-base);
}
.session-id {
font-size: 0.6875rem;
color: var(--color-text-muted);
font-family: monospace;
}
.show-in-graph-btn {
background: var(--color-interactive);
color: #fff;
border: none;
border-radius: 0.25rem;
padding: 0.25rem 0.625rem;
font-size: 0.6875rem;
font-family: inherit;
cursor: pointer;
}
.show-in-graph-btn:hover { background: var(--color-interactive-hover); }
/* ===== Entities Tab ===== */
.entities-toolbar {
display: flex;
gap: 0.75rem;
padding: 0.75rem 1.25rem;
background: var(--color-bg-elevated);
border-bottom: 1px solid var(--color-border-subtle);
}
.entities-container {
padding: 1rem 1.25rem;
overflow-y: auto;
}
.entity-group {
margin-bottom: 1rem;
}
.entity-group-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--color-bg-elevated);
border: none;
border-radius: 0.375rem;
cursor: pointer;
user-select: none;
margin-bottom: 0.5rem;
width: 100%;
font-family: inherit;
font-size: inherit;
color: inherit;
text-align: left;
}
.entity-group-header .dot {
width: 12px;
height: 12px;
border-radius: 3px;
}
.entity-group-header .label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-primary);
}
.entity-group-header .count {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.entity-group-header .chevron {
margin-left: auto;
color: var(--color-text-muted);
transition: transform 0.15s;
}
.entity-group-header.collapsed .chevron {
transform: rotate(-90deg);
}
.entity-group-items {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 0.5rem;
}
.entity-group-items.hidden { display: none; }
.entity-card {
background: var(--color-bg-surface);
border: 1px solid var(--color-border-default);
border-radius: 0.375rem;
padding: 0.75rem;
}
.entity-card .ent-header {
display: flex;
align-items: center;
gap: 0.375rem;
margin-bottom: 0.375rem;
}
.entity-card .ent-name {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-primary);
}
.entity-card .conn-count {
font-size: 0.6875rem;
color: var(--color-text-muted);
margin-left: auto;
}
.entity-card .obs-list {
font-size: 0.75rem;
color: var(--color-text-muted);
list-style: none;
margin-bottom: 0.375rem;
}
.entity-card .obs-list li {
padding: 0.125rem 0;
}
.entity-card .highlight-btn {
background: none;
border: 1px solid var(--color-border-input);
color: var(--color-text-muted);
border-radius: 0.25rem;
padding: 0.125rem 0.5rem;
font-size: 0.6875rem;
font-family: inherit;
cursor: pointer;
}
.entity-card .highlight-btn:hover {
border-color: var(--color-interactive);
color: var(--color-interactive);
}
/* ===== Stats Tab ===== */
.stats-container {
padding: 1.25rem;
overflow-y: auto;
}
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.stat-card {
background: var(--color-bg-surface);
border: 1px solid var(--color-border-default);
border-radius: 0.5rem;
padding: 1rem;
}
.stat-card h3 {
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
margin-bottom: 0.5rem;
}
.stat-card .value {
font-size: 1.75rem;
font-weight: 700;
color: var(--color-text-primary);
}
.stat-card .breakdown {
margin-top: 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.stat-card .breakdown .seg {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
color: var(--color-text-muted);
}
.stat-card .breakdown .seg .dot {
width: 8px;
height: 8px;
border-radius: 2px;
}
.timeline-section {
background: var(--color-bg-surface);
border: 1px solid var(--color-border-default);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1.25rem;
}
.timeline-section h3 {
font-size: 0.8125rem;
color: var(--color-text-primary);
margin-bottom: 0.75rem;
}
.timeline-bars {
display: flex;
align-items: flex-end;
gap: 2px;
height: 80px;
overflow-x: auto;
}
.timeline-bar {
min-width: 8px;
border-radius: 2px 2px 0 0;
cursor: pointer;
transition: opacity 0.15s;
}
.timeline-bar:hover { opacity: 0.7; }
.sessions-table-section {
background: var(--color-bg-surface);
border: 1px solid var(--color-border-default);
border-radius: 0.5rem;
padding: 1rem;
}
.sessions-table-section h3 {
font-size: 0.8125rem;
color: var(--color-text-primary);
margin-bottom: 0.75rem;
}
.sessions-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
}
.sessions-table th {
text-align: left;
padding: 0.5rem;
color: var(--color-text-muted);
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--color-border-default);
}
.sessions-table td {
padding: 0.5rem;
border-bottom: 1px solid var(--color-bg-base);
color: var(--color-text-body);
}
.sessions-table tr {
cursor: pointer;
}
.sessions-table tr:hover td {
background: var(--color-bg-elevated);
}
.sessions-table .mono {
font-family: monospace;
font-size: 0.75rem;
color: var(--color-text-muted);
}
/* ===== Utilities ===== */
.sort-btn {
background: none;
border: 1px solid var(--color-border-input);
color: var(--color-text-muted);
border-radius: 0.25rem;
padding: 0.25rem 0.5rem;
font-size: 0.6875rem;
font-family: inherit;
cursor: pointer;
}
.sort-btn:hover,
.sort-btn.active {
border-color: var(--color-interactive);
color: var(--color-interactive);
}
.overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.3);
z-index: 99;
}
.overlay.open { display: block; }
/* scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--color-scrollbar-track); }
::-webkit-scrollbar-thumb { background: var(--color-scrollbar-thumb); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--color-scrollbar-hover); }
* {
scrollbar-width: thin;
scrollbar-color: var(--color-scrollbar-thumb) var(--color-scrollbar-track);
}
/* ===== Empty States ===== */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
color: var(--color-text-muted);
text-align: center;
}
.empty-state .icon { font-size: 2.5rem; margin-bottom: 1rem; opacity: 0.5; }
.empty-state h3 { font-size: 1rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; }
.empty-state p { font-size: 0.875rem; margin-bottom: 1rem; }
.empty-state button {
background: var(--color-interactive);
color: #fff;
border: none;
border-radius: 0.25rem;
padding: 0.5rem 1rem;
font-size: 0.8125rem;
cursor: pointer;
}
.empty-state button:hover { background: var(--color-interactive-hover); }
/* ===== Legend ===== */
.graph-legend {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
padding: 0.5rem 1.25rem;
background: var(--color-bg-elevated);
border-top: 1px solid var(--color-border-subtle);
font-size: 0.6875rem;
color: var(--color-text-muted);
}
.legend-section { display: flex; align-items: center; gap: 0.5rem; }
.legend-section strong { color: var(--color-text-secondary); margin-right: 0.25rem; }
.legend-item { display: inline-flex; align-items: center; gap: 0.25rem; }
.legend-line { width: 20px; height: 2px; }
.legend-line.solid { background: var(--color-border-input); }
.legend-line.dashed { background: repeating-linear-gradient(90deg, var(--color-border-input) 0, var(--color-border-input) 6px, transparent 6px, transparent 10px); }
.legend-line.dotted { background: repeating-linear-gradient(90deg, var(--color-border-input) 0, var(--color-border-input) 2px, transparent 2px, transparent 5px); }
/* ===== Responsive ===== */
@media (max-width: 640px) {
.top-bar {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
}
.top-bar input[type="text"] { width: 100%; }
.tab-bar { overflow-x: auto; }
.graph-toolbar { gap: 0.5rem; }
.entity-toggle { font-size: 0.6875rem; }
.decisions-toolbar { flex-direction: column; gap: 0.5rem; }
.decision-card .card-footer { flex-direction: column; gap: 0.5rem; align-items: flex-start; }
.entity-group-items { grid-template-columns: 1fr; }
.stats-row { grid-template-columns: 1fr; }
.sessions-table-wrapper { overflow-x: auto; }
}
</style>
</head>
<body>
<a href="#main-content" class="skip-link">Skip to main content</a>
<!-- Top bar with session filter -->
<header class="top-bar">
<h1>Knowledge Graph Playground</h1>
<label for="session-filter" class="sr-only">Filter by session</label>
<select id="session-filter" aria-label="Filter by session">
<option value="all">All Sessions</option>
</select>
<label for="search-global" class="sr-only">Search entities</label>
<input type="text" id="search-global" placeholder="Search entities..." aria-label="Search entities in graph">
</header>
<!-- Tab bar -->
<nav class="tab-bar" role="tablist" aria-label="Content sections">
<button class="tab-btn active" data-tab="graph" role="tab" aria-selected="true" aria-controls="tab-graph" id="tab-btn-graph" tabindex="0">Graph</button>
<button class="tab-btn" data-tab="decisions" role="tab" aria-selected="false" aria-controls="tab-decisions" id="tab-btn-decisions" tabindex="-1">Decisions</button>
<button class="tab-btn" data-tab="entities" role="tab" aria-selected="false" aria-controls="tab-entities" id="tab-btn-entities" tabindex="-1">Entities</button>
<button class="tab-btn" data-tab="stats" role="tab" aria-selected="false" aria-controls="tab-stats" id="tab-btn-stats" tabindex="-1">Stats</button>
</nav>
<main id="main-content">
<!-- Graph Tab -->
<div class="tab-content active" id="tab-graph" role="tabpanel" aria-labelledby="tab-btn-graph">
<div class="graph-toolbar" id="entity-toggles" role="group" aria-label="Entity type filters"></div>
<div id="graph-canvas" role="application" aria-label="Interactive knowledge graph visualization" aria-roledescription="force-directed graph"></div>
<div class="graph-legend" id="graph-legend" aria-label="Graph legend">
<div class="legend-section">
<strong>Edges:</strong>
<span class="legend-item"><span class="legend-line solid"></span> Strong (CHOSE, SOLVED_BY)</span>
<span class="legend-item"><span class="legend-line dashed"></span> Rejected (CHOSE_OVER)</span>
<span class="legend-item"><span class="legend-line dotted"></span> Weak (MENTIONS)</span>
</div>
<div class="legend-section">
<strong>Tip:</strong> Double-click node to focus neighborhood, click edge for details
</div>
</div>
<div class="graph-statusbar" role="status" aria-live="polite">
<span id="status-nodes">0 nodes</span>
<span id="status-edges">0 edges</span>
<span id="status-sessions">0 sessions</span>
</div>
</div>
<!-- Decisions Tab -->
<div class="tab-content" id="tab-decisions" role="tabpanel" aria-labelledby="tab-btn-decisions">
<div class="decisions-toolbar">
<label for="decisions-category-filter" class="sr-only">Filter by category</label>
<select id="decisions-category-filter" aria-label="Filter by category">
<option value="all">All Categories</option>
</select>
<label for="decisions-search" class="sr-only">Search decisions</label>
<input type="text" id="decisions-search" placeholder="Search decisions..." aria-label="Search decisions">
<div role="group" aria-label="Sort options">
<button class="sort-btn active" data-sort="newest" aria-pressed="true">Newest</button>
<button class="sort-btn" data-sort="type" aria-pressed="false">Type</button>
<button class="sort-btn" data-sort="confidence" aria-pressed="false">Confidence</button>
</div>
</div>
<div class="decision-list" id="decision-list" aria-live="polite"></div>
</div>
<!-- Entities Tab -->
<div class="tab-content" id="tab-entities" role="tabpanel" aria-labelledby="tab-btn-entities">
<div class="entities-toolbar">
<label for="entities-search" class="sr-only">Search entities</label>
<input type="text" id="entities-search" placeholder="Search entities..." aria-label="Search entities">
</div>
<div class="entities-container" id="entities-container" aria-live="polite"></div>
</div>
<!-- Stats Tab -->
<div class="tab-content" id="tab-stats" role="tabpanel" aria-labelledby="tab-btn-stats">
<div class="stats-container" id="stats-container"></div>
</div>
</main>
<!-- Detail panel (slide-in) -->
<div class="overlay" id="detail-overlay" aria-hidden="true"></div>
<div class="detail-panel" id="detail-panel" role="dialog" aria-modal="true" aria-labelledby="detail-title" aria-hidden="true">
<button class="close-btn" id="detail-close" aria-label="Close detail panel">×</button>
<div id="detail-content"></div>
</div>
<script>
// ===== Embedded Data =====
const DATA = {{PLAYGROUND_DATA}};
// ===== Color map =====
const TYPE_COLORS = {
Decision: '#3B82F6', Preference: '#10B981', Problem: '#EF4444',
Solution: '#22C55E', Technology: '#F59E0B', Pattern: '#8B5CF6',
Tool: '#06B6D4', Workflow: '#EC4899'
};
// ===== Named Constants =====
const DEBOUNCE_MS = 300;
const PHYSICS_ITERATIONS = 150;
const SESSION_ID_LENGTH = 8;
const GRAVITATIONAL_CONSTANT = -3000;
const SPRING_LENGTH = 250;
const SPRING_CONSTANT = 0.02;
const NODE_BASE_SIZE = 10;
const NODE_SIZE_PER_CONNECTION = 3;
const MAX_OBSERVATIONS = 5;
const MAX_CONNECTED_NAMES = 5;
const FOCUS_DELAY_MS = 100;
const HIGHLIGHT_DURATION_MS = 500;
const DETAIL_DELAY_MS = 300;
const TOOLTIP_DELAY_MS = 200;
const KEYBOARD_SPEED = { x: 10, y: 10, zoom: 0.02 };
// ===== Node ID Helper =====
// Uses pre-computed map from server, with inline fallback for edge cases
function getNodeIdForEntity(entityType, name) {
if (DATA.entityNodeIds?.[name]) return DATA.entityNodeIds[name];
const prefixes = {Decision:'d_',Preference:'pref_',Problem:'prob_',Solution:'sol_',
Technology:'t_',Pattern:'p_',Tool:'tool_',Workflow:'w_'};
return (prefixes[entityType] || 'p_') + name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
}
// ===== Lookup Maps for O(1) access =====
const nodeById = new Map(DATA.nodes.map(n => [n.id, n]));
const edgesByNodeId = new Map();
DATA.nodes.forEach(n => edgesByNodeId.set(n.id, []));
DATA.edges.forEach((e, i) => {
if (edgesByNodeId.has(e.from)) edgesByNodeId.get(e.from).push({ ...e, _idx: i });
if (edgesByNodeId.has(e.to)) edgesByNodeId.get(e.to).push({ ...e, _idx: i });
});
// ===== State =====
let currentSession = 'all';
let network = null;
let allNodes = null;
let allEdges = null;
let visibleTypes = new Set(Object.keys(TYPE_COLORS));
let lastFocusedElement = null; // For focus restoration
// ===== Debounce utility =====
function debounce(fn, ms) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => fn(...args), ms);
};
}
// ===== Init =====
document.addEventListener('DOMContentLoaded', () => {
populateSessionFilter();
initTabs();
initEntityToggles();
initGraph();
renderDecisions();
renderEntities();
renderStats();
initDetailPanel();
});
// ===== Session Filter =====
function populateSessionFilter() {
const sel = document.getElementById('session-filter');
for (const s of DATA.sessions) {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = s.label;
sel.appendChild(opt);
}
sel.addEventListener('change', () => {
currentSession = sel.value;
applySessionFilter();
});
}
function applySessionFilter() {
filterGraph();
renderDecisions();
renderEntities();
renderStats();
}
function getFilteredNodes() {
if (currentSession === 'all') return DATA.nodes;
return DATA.nodes.filter(n => n.sessionIds.includes(currentSession));
}
function getFilteredEdges() {
if (currentSession === 'all') return DATA.edges;
return DATA.edges.filter(e => e.sessionId === currentSession);
}
function getFilteredDecisions() {
if (currentSession === 'all') return DATA.decisions;
return DATA.decisions.filter(d => d.metadata?.session_id === currentSession);
}
// ===== Tabs =====
function initTabs() {
const tabBtns = document.querySelectorAll('.tab-btn');
tabBtns.forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
// Roving tabindex: arrow keys move focus AND activate tab
btn.addEventListener('keydown', (e) => {
const tabs = [...tabBtns];
const idx = tabs.indexOf(btn);
let nextIdx = -1;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
nextIdx = (idx + 1) % tabs.length;
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
nextIdx = (idx - 1 + tabs.length) % tabs.length;
} else if (e.key === 'Home') {
e.preventDefault();
nextIdx = 0;
} else if (e.key === 'End') {
e.preventDefault();
nextIdx = tabs.length - 1;
}
if (nextIdx >= 0) {
switchTab(tabs[nextIdx].dataset.tab);
tabs[nextIdx].focus();
}
});
});
}
function switchTab(tabName) {
document.querySelectorAll('.tab-btn').forEach(b => {
b.classList.remove('active');
b.setAttribute('aria-selected', 'false');
b.setAttribute('tabindex', '-1');
});
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
const btn = document.querySelector(`[data-tab="${tabName}"]`);
btn.classList.add('active');
btn.setAttribute('aria-selected', 'true');
btn.setAttribute('tabindex', '0');
document.getElementById('tab-' + tabName).classList.add('active');
if (tabName === 'graph' && network) {
setTimeout(() => network.fit(), 100);
}
}
function switchToGraphTab(nodeIds, openDetailForFirst = false) {
switchTab('graph');
if (network && nodeIds && nodeIds.length > 0) {
const existing = nodeIds.filter(id => {
try { return allNodes.get(id) !== null; } catch { return false; }
});
if (existing.length > 0) {
network.selectNodes(existing);
network.focus(existing[0], { scale: 1.2, animation: true });
// Highlight animation
existing.forEach(id => {
const origColor = allNodes.get(id)?.color;
allNodes.update({ id, color: { background: '#fff', border: '#fff' } });
setTimeout(() => {
if (origColor) allNodes.update({ id, color: origColor });
}, HIGHLIGHT_DURATION_MS);
});
// Auto-open detail panel for first node
if (openDetailForFirst) {
setTimeout(() => showNodeDetail(existing[0]), DETAIL_DELAY_MS);
}
}
}
}
// ===== Entity type toggles =====
function initEntityToggles() {
const container = document.getElementById('entity-toggles');
for (const [type, color] of Object.entries(TYPE_COLORS)) {
const label = document.createElement('label');
label.className = 'entity-toggle';
label.innerHTML = `<input type="checkbox" checked data-type="${type}"><span class="dot" style="background:${color}"></span>${type}`;
container.appendChild(label);
}
container.addEventListener('change', (e) => {
if (e.target.type === 'checkbox') {
const t = e.target.dataset.type;
if (e.target.checked) visibleTypes.add(t);
else visibleTypes.delete(t);
filterGraph();
}
});
}
// ===== Graph =====
function initGraph() {
const container = document.getElementById('graph-canvas');
const nodes = DATA.nodes.map(n => ({
id: n.id,
label: n.name,
color: { background: n.color, border: n.color, highlight: { background: n.color, border: '#fff' } },
size: NODE_BASE_SIZE + n.connectionCount * NODE_SIZE_PER_CONNECTION,
shape: 'dot',
font: { color: '#e2e8f0', size: 11 },
title: `${n.name}\nType: ${n.entityType}\nObservations: ${n.observations.length}\nConnections: ${n.connectionCount}`,
_entityType: n.entityType,
_sessionIds: n.sessionIds,
}));
const edges = DATA.edges.map((e, i) => {
const dashes = e.edgeCategory === 'rejected' ? [10, 5]
: e.edgeCategory === 'weak' ? [3, 3]
: false;
return {
id: 'e' + i,
from: e.from,
to: e.to,
label: e.relationType,
dashes,
arrows: { to: { enabled: true, scaleFactor: 0.6 } },
color: { color: '#475569', highlight: '#3B82F6' },
font: { color: '#64748b', size: 9, strokeWidth: 0 },
_sessionId: e.sessionId,
_relationType: e.relationType,
_edgeCategory: e.edgeCategory,
};
});
allNodes = new vis.DataSet(nodes);
allEdges = new vis.DataSet(edges);
network = new vis.Network(container, { nodes: allNodes, edges: allEdges }, {
physics: {
barnesHut: {
gravitationalConstant: GRAVITATIONAL_CONSTANT,
springLength: SPRING_LENGTH,
springConstant: SPRING_CONSTANT,
},
stabilization: { iterations: PHYSICS_ITERATIONS },
},
interaction: {
hover: true,
tooltipDelay: TOOLTIP_DELAY_MS,
navigationButtons: true, // Enable for better accessibility
keyboard: {
enabled: true,
speed: KEYBOARD_SPEED,
bindToWindow: false,
},
},
layout: { improvedLayout: true },
});
network.on('click', (params) => {
if (params.nodes.length > 0) {
showNodeDetail(params.nodes[0]);
} else if (params.edges.length > 0) {
showEdgeDetail(params.edges[0]);
} else {
closeDetail();
}
});
network.on('doubleClick', (params) => {
if (params.nodes.length > 0) {
const nodeId = params.nodes[0];
const connectedNodes = network.getConnectedNodes(nodeId);
const focusNodes = [nodeId, ...connectedNodes];
network.fit({ nodes: focusNodes, animation: true });
}
});
// Search with debouncing and batch updates
const handleGlobalSearch = debounce((q) => {
const updates = [];
allNodes.forEach(n => {
if (!q) {
updates.push({ id: n.id, opacity: 1, font: { color: '#e2e8f0', size: 11 } });
} else {
const match = n.label.toLowerCase().includes(q);
updates.push({
id: n.id,
opacity: match ? 1 : 0.15,
font: { color: match ? '#fff' : '#334155', size: match ? 13 : 9 },
});
}
});
allNodes.update(updates); // Batch update
}, DEBOUNCE_MS);
document.getElementById('search-global').addEventListener('input', (e) => {
handleGlobalSearch(e.target.value.toLowerCase());
});
updateGraphStatus();
}
function filterGraph() {
if (!allNodes) return;
const filteredNodeIds = new Set();
const nodeUpdates = [];
const edgeUpdates = [];
DATA.nodes.forEach(n => {
const typeVisible = visibleTypes.has(n.entityType);
const sessionMatch = currentSession === 'all' || n.sessionIds.includes(currentSession);
const visible = typeVisible && sessionMatch;
if (visible) filteredNodeIds.add(n.id);
const existing = allNodes.get(n.id);
if (existing) {
nodeUpdates.push({ id: n.id, hidden: !visible });
}
});
DATA.edges.forEach((e, i) => {
const id = 'e' + i;
const sessionMatch = currentSession === 'all' || e.sessionId === currentSession;
const bothVisible = filteredNodeIds.has(e.from) && filteredNodeIds.has(e.to);
const existing = allEdges.get(id);
if (existing) {
edgeUpdates.push({ id, hidden: !(sessionMatch && bothVisible) });
}
});
// Batch updates for better performance
allNodes.update(nodeUpdates);
allEdges.update(edgeUpdates);
updateGraphStatus();
}
function updateGraphStatus() {
let visNodes = 0, visEdges = 0;
allNodes.forEach(n => { if (!n.hidden) visNodes++; });
allEdges.forEach(e => { if (!e.hidden) visEdges++; });
document.getElementById('status-nodes').textContent = visNodes + ' nodes';
document.getElementById('status-edges').textContent = visEdges + ' edges';
document.getElementById('status-sessions').textContent = DATA.sessions.length + ' sessions';
}
// ===== Detail Panel =====
function initDetailPanel() {
const closeBtn = document.getElementById('detail-close');
const overlay = document.getElementById('detail-overlay');
const panel = document.getElementById('detail-panel');
closeBtn.addEventListener('click', closeDetail);
overlay.addEventListener('click', closeDetail);
// Escape key to close
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && panel.classList.contains('open')) {
closeDetail();
}
});
// Focus trap within dialog
panel.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
const focusable = panel.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
});
}
function openDetail(html, titleText = 'Details') {
const panel = document.getElementById('detail-panel');
const overlay = document.getElementById('detail-overlay');
const content = document.getElementById('detail-content');
// Store last focused element for restoration
lastFocusedElement = document.activeElement;
content.innerHTML = html;
panel.classList.add('open');
overlay.classList.add('open');
panel.setAttribute('aria-hidden', 'false');
overlay.setAttribute('aria-hidden', 'false');
// Move focus to close button
setTimeout(() => {
document.getElementById('detail-close').focus();
}, FOCUS_DELAY_MS);
}
function closeDetail() {
const panel = document.getElementById('detail-panel');
const overlay = document.getElementById('detail-overlay');
panel.classList.remove('open');
overlay.classList.remove('open');
panel.setAttribute('aria-hidden', 'true');
overlay.setAttribute('aria-hidden', 'true');
// Restore focus
if (lastFocusedElement) {
lastFocusedElement.focus();
lastFocusedElement = null;
}
}
function showNodeDetail(nodeId) {
const node = nodeById.get(nodeId);
if (!node) return;
// Use lookup map for O(1) edge access
const connectedEdges = edgesByNodeId.get(nodeId) || [];
const connectedNodeIds = new Set();
connectedEdges.forEach(e => {
connectedNodeIds.add(e.from === nodeId ? e.to : e.from);
});
const connectedNodes = [...connectedNodeIds].map(id => nodeById.get(id)).filter(Boolean);
// Find source decisions
const sourceRecordSet = new Set(node.sourceRecordIds || []);
const sourceDecisions = DATA.decisions.filter(d => sourceRecordSet.has(d.id));
let html = `
<h2 id="detail-title">${esc(node.name)}</h2>
<div class="type-badge" style="background:${node.color}">${esc(node.entityType)}</div>
<div class="detail-section">
<h3>Observations (${node.observations.length})</h3>
<ul>${node.observations.map(o => `<li>${esc(o)}</li>`).join('') || '<li>None</li>'}</ul>
</div>
<div class="detail-section">
<h3>Connected Entities (${connectedNodes.length})</h3>
<ul>${connectedNodes.map(cn => `<li><span style="color:${cn.color}">\u25CF</span> ${esc(cn.name)}</li>`).join('') || '<li>None</li>'}</ul>
</div>
<div class="detail-section">
<h3>Relations</h3>
<ul>${connectedEdges.map(e => {
const fromNode = nodeById.get(e.from);
const toNode = nodeById.get(e.to);
return `<li>${esc(fromNode?.name || e.from)} → <span style="color:#3B82F6">${esc(e.relationType)}</span> → ${esc(toNode?.name || e.to)}</li>`;
}).join('') || '<li>None</li>'}</ul>
</div>
<div class="detail-section">
<h3>Source Decisions (${sourceDecisions.length})</h3>
<ul>${sourceDecisions.map(d => `<li>${esc(d.content?.what || d.id)}</li>`).join('') || '<li>None</li>'}</ul>
</div>
<div class="detail-section">
<h3>Sessions</h3>
<ul>${node.sessionIds.map(s => `<li class="mono" style="font-family:monospace;font-size:0.75rem;color:#94a3b8">${esc(s.slice(0,SESSION_ID_LENGTH))}</li>`).join('')}</ul>
</div>`;
openDetail(html, node.name);
}
function showEdgeDetail(edgeId) {
const idx = parseInt(edgeId.replace('e', ''), 10);
const edge = DATA.edges[idx];
if (!edge) return;
const fromNode = nodeById.get(edge.from);
const toNode = nodeById.get(edge.to);
let html = `
<h2 id="detail-title">Relation</h2>
<div class="detail-section">
<h3>Type</h3>
<p style="font-size:0.875rem;color:#3B82F6;font-weight:600">${esc(edge.relationType)}</p>
</div>
<div class="detail-section">
<h3>Category</h3>
<p style="font-size:0.8125rem;color:#94a3b8">${esc(edge.edgeCategory)}</p>
</div>
<div class="detail-section">
<h3>From</h3>
<p style="font-size:0.8125rem"><span style="color:${fromNode?.color || '#fff'}">\u25CF</span> ${esc(fromNode?.name || edge.from)}</p>
</div>
<div class="detail-section">
<h3>To</h3>
<p style="font-size:0.8125rem"><span style="color:${toNode?.color || '#fff'}">\u25CF</span> ${esc(toNode?.name || edge.to)}</p>
</div>
<div class="detail-section">
<h3>Session</h3>
<p style="font-family:monospace;font-size:0.75rem;color:#94a3b8">${esc(edge.sessionId?.slice(0,SESSION_ID_LENGTH) || 'unknown')}</p>
</div>`;
openDetail(html, 'Relation');
}
// ===== Decisions Tab =====
const debouncedRenderDecisions = debounce(() => renderDecisions(), DEBOUNCE_MS);
function renderDecisions() {
const container = document.getElementById('decision-list');
let decisions = getFilteredDecisions();
// Populate category dropdown
const catSelect = document.getElementById('decisions-category-filter');
const cats = new Set(DATA.decisions.map(d => d.metadata?.category || 'general'));
// Only rebuild options if needed
if (catSelect.options.length <= 1) {
for (const c of [...cats].sort()) {
const opt = document.createElement('option');
opt.value = c;
opt.textContent = c;
catSelect.appendChild(opt);
}
catSelect.addEventListener('change', () => renderDecisions());
}
// Apply category filter
const catFilter = catSelect.value;
if (catFilter !== 'all') {
decisions = decisions.filter(d => (d.metadata?.category || 'general') === catFilter);
}
// Apply search
const searchQ = document.getElementById('decisions-search').value.toLowerCase();
if (searchQ) {
decisions = decisions.filter(d =>
(d.content?.what || '').toLowerCase().includes(searchQ) ||
(d.content?.why || '').toLowerCase().includes(searchQ)
);
}
// Apply sort
const activeSort = document.querySelector('.sort-btn.active')?.dataset.sort || 'newest';
if (activeSort === 'newest') {
decisions.sort((a, b) => (b.metadata?.timestamp || '').localeCompare(a.metadata?.timestamp || ''));
} else if (activeSort === 'type') {
decisions.sort((a, b) => (a.type || '').localeCompare(b.type || ''));
} else if (activeSort === 'confidence') {
decisions.sort((a, b) => (b.metadata?.confidence || 0) - (a.metadata?.confidence || 0));
}
// Empty state
if (decisions.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="icon">📋</div>
<h3>No decisions found</h3>
<p>${searchQ ? `No results for "${esc(searchQ)}"` : catFilter !== 'all' ? `No decisions in category "${esc(catFilter)}"` : 'No decisions recorded yet.'}</p>
${searchQ || catFilter !== 'all' ? '<button onclick="document.getElementById(\'decisions-search\').value=\'\';document.getElementById(\'decisions-category-filter\').value=\'all\';renderDecisions()">Clear Filters</button>' : ''}
</div>`;
return;
}
container.innerHTML = decisions.map(d => {
const typeColor = TYPE_COLORS[capitalize(d.type)] || '#94a3b8';
const conf = d.metadata?.confidence || 0;
const ts = d.metadata?.timestamp ? new Date(d.metadata.timestamp).toLocaleDateString() : '';
const entitiesHtml = (d.entities || []).map(e => {
const ent = typeof e === 'string' ? { name: e, entityType: 'Pattern' } : e;
const c = TYPE_COLORS[ent.entityType] || '#8B5CF6';
const nodeId = getNodeIdForEntity(ent.entityType, ent.name);
// Use dark text for light badges (amber, green, cyan)
const textColor = ['Technology', 'Solution', 'Tool'].includes(ent.entityType) ? '#0f172a' : '#fff';
return `<span class="entity-chip" style="background:${c};color:${textColor}" data-entity="${esc(ent.name)}" data-node-id="${nodeId}" role="button" tabindex="0" onclick="switchToGraphTab(['${nodeId}'], true)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();switchToGraphTab(['${nodeId}'], true)}">${esc(ent.name)}</span>`;
}).join('');
const relationsHtml = (d.relations || []).map(r =>
`<span>${esc(r.from)}</span> → ${esc(r.relationType)} → <span>${esc(r.to)}</span>`
).join('<br>');
const alternativesHtml = d.content?.alternatives?.length
? `<div class="alternatives">Alternatives: ${d.content.alternatives.map(a => esc(a)).join(', ')}</div>`
: '';
// Collect node IDs for "Show in Graph"
const nodeIds = (d.entities || []).map(e => {
const ent = typeof e === 'string' ? { name: e, entityType: 'Pattern' } : e;
return getNodeIdForEntity(ent.entityType || 'Pattern', ent.name);
});
return `<div class="decision-card">
<div class="card-header">
<span class="type-badge" style="background:${typeColor}">${esc(d.type || 'unknown')}</span>
<span class="what">${esc(d.content?.what || d.id)}</span>
<span class="timestamp">${ts}</span>
<span class="confidence-bar" role="progressbar" aria-valuenow="${Math.round(conf * 100)}" aria-valuemin="0" aria-valuemax="100"><span class="bar" aria-hidden="true"><span class="fill" style="width:${conf * 100}%"></span></span>${(conf * 100).toFixed(0)}%</span>
</div>
${d.content?.why ? `<div class="why">${esc(d.content.why)}</div>` : ''}
${alternativesHtml}
<div class="entity-chips">${entitiesHtml}</div>
${relationsHtml ? `<div class="relation-list">${relationsHtml}</div>` : ''}
<div class="card-footer">
<span class="session-id">${esc((d.metadata?.session_id || '').slice(0, SESSION_ID_LENGTH))}</span>
<button class="show-in-graph-btn" onclick='switchToGraphTab(${JSON.stringify(nodeIds)}, true)'>Show in Graph</button>
</div>
</div>`;
}).join('');
// Search listener (only attach once) with debounce
const searchInput = document.getElementById('decisions-search');
if (!searchInput._attached) {
searchInput._attached = true;
searchInput.addEventListener('input', debouncedRenderDecisions);
}
// Sort buttons
document.querySelectorAll('.sort-btn').forEach(btn => {
if (!btn._attached) {
btn._attached = true;
btn.addEventListener('click', () => {
document.querySelectorAll('.sort-btn').forEach(b => {
b.classList.remove('active');
b.setAttribute('aria-pressed', 'false');
});
btn.classList.add('active');
btn.setAttribute('aria-pressed', 'true');
renderDecisions();
});
}
});
}
// ===== Entities Tab =====
const debouncedRenderEntities = debounce(() => renderEntities(), DEBOUNCE_MS);
function toggleEntityGroup(header) {
const isCollapsed = header.classList.toggle('collapsed');
header.setAttribute('aria-expanded', !isCollapsed);
header.nextElementSibling.classList.toggle('hidden');
}
function renderEntities() {
const container = document.getElementById('entities-container');
const filteredNodes = getFilteredNodes();
const searchQ = document.getElementById('entities-search').value.toLowerCase();
const filtered = searchQ
? filteredNodes.filter(n => n.name.toLowerCase().includes(searchQ) || n.entityType.toLowerCase().includes(searchQ))
: filteredNodes;
// Group by type
const groups = {};
for (const n of filtered) {
if (!groups[n.entityType]) groups[n.entityType] = [];
groups[n.entityType].push(n);
}
// Empty state
if (filtered.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="icon">🔍</div>
<h3>No entities found</h3>
<p>${searchQ ? `No results for "${esc(searchQ)}"` : 'No entities in the current session.'}</p>
${searchQ ? '<button onclick="document.getElementById(\'entities-search\').value=\'\';renderEntities()">Clear Search</button>' : ''}
</div>`;
return;
}
container.innerHTML = Object.entries(TYPE_COLORS).map(([type, color]) => {
const items = groups[type] || [];
if (items.length === 0) return '';
// Use dark text for light badges
const textColor = ['Technology', 'Solution', 'Tool'].includes(type) ? '#0f172a' : '#fff';
return `<div class="entity-group">
<button class="entity-group-header" type="button" aria-expanded="true" onclick="toggleEntityGroup(this)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleEntityGroup(this)}">
<span class="dot" style="background:${color}" aria-hidden="true"></span>
<span class="label">${esc(type)}</span>
<span class="count">(${items.length})</span>
<span class="chevron" aria-hidden="true">\u25BC</span>
</button>
<div class="entity-group-items" role="region" aria-label="${type} entities">
${items.map(n => {
// Use lookup maps for O(1) access
const connEdges = edgesByNodeId.get(n.id) || [];
const connNodes = new Set();
connEdges.forEach(e => connNodes.add(e.from === n.id ? e.to : e.from));
const connNames = [...connNodes].map(cid => {
const cn = nodeById.get(cid);
return cn ? cn.name : cid;
});
return `<div class="entity-card">
<div class="ent-header">
<span class="type-badge" style="background:${color};color:${textColor}">${esc(type)}</span>
<span class="ent-name">${esc(n.name)}</span>
<span class="conn-count">${n.connectionCount} connections</span>
</div>
${n.observations.length > 0 ? `<ul class="obs-list">${n.observations.slice(0, MAX_OBSERVATIONS).map(o => `<li>- ${esc(o)}</li>`).join('')}${n.observations.length > MAX_OBSERVATIONS ? `<li style="color:var(--color-text-muted)">...and ${n.observations.length - MAX_OBSERVATIONS} more</li>` : ''}</ul>` : ''}
${connNames.length > 0 ? `<div style="font-size:0.75rem;color:var(--color-text-muted);margin-bottom:0.375rem">Connected: ${connNames.slice(0, MAX_CONNECTED_NAMES).map(c => esc(c)).join(', ')}${connNames.length > MAX_CONNECTED_NAMES ? ` +${connNames.length - MAX_CONNECTED_NAMES}` : ''}</div>` : ''}
<button class="highlight-btn" onclick="switchToGraphTab(['${n.id}'], true)">Show in Graph</button>
</div>`;
}).join('')}
</div>
</div>`;
}).join('');
// Search listener with debounce
const searchInput = document.getElementById('entities-search');
if (!searchInput._attached) {
searchInput._attached = true;
searchInput.addEventListener('input', debouncedRenderEntities);
}
}
// ===== Stats Tab =====
function renderStats() {
const container = document.getElementById('stats-container');
const filteredDecisions = getFilteredDecisions();
const filteredNodes = getFilteredNodes();
const filteredEdges = getFilteredEdges();
// Recalculate stats for filtered data
const entityCountsByType = {};
filteredNodes.forEach(n => { entityCountsByType[n.entityType] = (entityCountsByType[n.entityType] || 0) + 1; });
const relationCountsByType = {};
filteredEdges.forEach(e => { relationCountsByType[e.relationType] = (relationCountsByType[e.relationType] || 0) + 1; });
const decisionCountsByCategory = {};
filteredDecisions.forEach(d => {
const cat = d.metadata?.category || 'general';
decisionCountsByCategory[cat] = (decisionCountsByCategory[cat] || 0) + 1;
});
// Summary cards
const summaryHtml = `<div class="stats-row">
<div class="stat-card">
<h3>Total Entities</h3>
<div class="value">${filteredNodes.length}</div>
<div class="breakdown">${Object.entries(entityCountsByType).map(([t, c]) =>
`<span class="seg"><span class="dot" style="background:${TYPE_COLORS[t] || '#64748b'}"></span>${esc(t)}: ${c}</span>`
).join('')}</div>
</div>
<div class="stat-card">
<h3>Total Relations</h3>
<div class="value">${filteredEdges.length}</div>
<div class="breakdown">${Object.entries(relationCountsByType).map(([t, c]) =>
`<span class="seg"><span class="dot" style="background:#475569"></span>${esc(t)}: ${c}</span>`
).join('')}</div>
</div>
<div class="stat-card">
<h3>Total Decisions</h3>
<div class="value">${filteredDecisions.length}</div>
<div class="breakdown">${Object.entries(decisionCountsByCategory).map(([t, c]) =>
`<span class="seg"><span class="dot" style="background:#3B82F6"></span>${esc(t)}: ${c}</span>`
).join('')}</div>
</div>
<div class="stat-card">
<h3>Queue Depth</h3>
<div class="value">${DATA.stats.queueDepth}</div>
<div class="breakdown"><span class="seg">Pending graph operations</span></div>
</div>
</div>`;
// Timeline
const sortedDecisions = [...filteredDecisions]
.filter(d => d.metadata?.timestamp)
.sort((a, b) => (a.metadata.timestamp).localeCompare(b.metadata.timestamp));
const maxEntities = Math.max(1, ...sortedDecisions.map(d => (d.entities || []).length));
const timelineHtml = sortedDecisions.length > 0 ? `<div class="timeline-section">
<h3>Decision Timeline</h3>
<div class="timeline-bars">
${sortedDecisions.map(d => {
const entCount = (d.entities || []).length;
const height = Math.max(8, (entCount / maxEntities) * 80);
const typeColor = TYPE_COLORS[capitalize(d.type)] || '#64748b';
const tooltip = esc(d.content?.what || d.id);
return `<div class="timeline-bar" style="height:${height}px;background:${typeColor};flex:1" title="${tooltip}"></div>`;
}).join('')}
</div>
</div>` : '';
// Sessions table with keyboard accessibility
const sessionsHtml = DATA.sessions.length > 0 ? `<div class="sessions-table-section">
<h3>Sessions</h3>
<div class="sessions-table-wrapper" style="overflow-x:auto">
<table class="sessions-table">
<thead>
<tr><th scope="col">Session ID</th><th scope="col">Date</th><th scope="col">Decisions</th><th scope="col">Entities</th></tr>
</thead>
<tbody>
${DATA.sessions.map(s => {
const date = s.startedAt ? new Date(s.startedAt).toLocaleDateString() : 'Unknown';
return `<tr tabindex="0" role="button" onclick="selectSession('${esc(s.id)}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();selectSession('${esc(s.id)}')}">
<td class="mono">${esc(s.id.slice(0, SESSION_ID_LENGTH))}</td>
<td>${esc(date)}</td>
<td>${s.decisionCount}</td>
<td>${s.entityCount}</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>
</div>` : `<div class="empty-state">
<div class="icon">📊</div>
<h3>No sessions</h3>
<p>Session data will appear here once you start using the knowledge graph.</p>
</div>`;
container.innerHTML = summaryHtml + timelineHtml + sessionsHtml;
}
// Make selectSession globally available
window.selectSession = function(sessionId) {
document.getElementById('session-filter').value = sessionId;
currentSession = sessionId;
applySessionFilter();
};
// ===== Helpers =====
function esc(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
}
function capitalize(s) {
if (!s) return '';
// Map decision types to entity type names
const map = { decision: 'Decision', preference: 'Preference', 'problem-solution': 'Problem', pattern: 'Pattern', workflow: 'Workflow' };
return map[s] || s.charAt(0).toUpperCase() + s.slice(1);
}
// Node ID helpers replaced by getNodeIdForEntity() defined above
</script>
</body>
</html>
#!/usr/bin/env node
/**
* render-graph.mjs - Render OrchestKit knowledge graph as interactive HTML
*
* Usage:
* node scripts/render-graph.mjs [--layout LR] [--category <cat>] [--recent N]
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
ENTITY_TYPES,
makeNodeId,
inferEntityType,
normalizeEntity,
normalizeRelation,
getProjectDir,
readJsonl,
openInBrowser,
} from './graph-utils.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
// ---------------------------------------------------------------------------
// Script-specific config
// ---------------------------------------------------------------------------
const EDGE_SYNTAX = {
CHOSE: '-->',
CHOSE_OVER: '-..->',
MENTIONS: '-.->',
CONSTRAINT: '-->',
TRADEOFF: '-..->',
RELATES_TO: '-.->',
SOLVED_BY: '-->',
PREFERS: '-->',
};
const SUBGRAPH_LABELS = {
Decision: 'Decisions',
Preference: 'Preferences',
Problem: 'Problems',
Solution: 'Solutions',
Technology: 'Technologies',
Pattern: 'Patterns',
Tool: 'Tools',
Workflow: 'Workflows',
};
// ---------------------------------------------------------------------------
// CLI args
// ---------------------------------------------------------------------------
function parseArgs(argv) {
const args = { layout: 'TD', category: null, recent: null, limit: 50 };
for (let i = 2; i < argv.length; i++) {
switch (argv[i]) {
case '--layout': args.layout = argv[++i] || 'TD'; break;
case '--category': args.category = argv[++i]; break;
case '--recent': args.recent = parseInt(argv[++i], 10); break;
case '--limit': args.limit = parseInt(argv[++i], 10); break;
}
}
return args;
}
// ---------------------------------------------------------------------------
// Mermaid helpers
// ---------------------------------------------------------------------------
function truncateLabel(label, max = 40) {
return label.length > max ? label.slice(0, max - 1) + '\u2026' : label;
}
function escMermaid(str) {
return str.replace(/"/g, "'").replace(/[[\]{}()#&]/g, ' ').trim();
}
function darken(hex) {
const r = Math.round(parseInt(hex.slice(1, 3), 16) * 0.7);
const g = Math.round(parseInt(hex.slice(3, 5), 16) * 0.7);
const b = Math.round(parseInt(hex.slice(5, 7), 16) * 0.7);
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}
// ---------------------------------------------------------------------------
// Build graph model from decisions
// ---------------------------------------------------------------------------
function buildGraphModel(records) {
const entityMap = new Map();
const relations = [];
for (const rec of records) {
const primaryType = rec.type === 'decision' ? 'Decision'
: rec.type === 'preference' ? 'Preference'
: rec.type === 'problem-solution' ? 'Problem'
: rec.type === 'pattern' ? 'Pattern'
: rec.type === 'workflow' ? 'Workflow'
: 'Decision';
const primaryName = rec.content?.what || rec.id || 'Unknown';
mergeEntity(entityMap, {
name: primaryName,
entityType: primaryType,
observations: rec.content?.why ? [rec.content.why] : [],
});
if (Array.isArray(rec.entities)) {
for (const raw of rec.entities) {
const entity = normalizeEntity(raw);
mergeEntity(entityMap, entity);
}
}
if (Array.isArray(rec.relations)) {
for (const rel of rec.relations) {
relations.push(normalizeRelation(rel));
}
}
if (rec.content?.alternatives && rec.entities?.length > 0) {
const chosen = Array.isArray(rec.entities)
? normalizeEntity(rec.entities[0]).name
: primaryName;
for (const alt of rec.content.alternatives) {
mergeEntity(entityMap, { name: alt, entityType: inferEntityType(alt), observations: [`Rejected alternative`] });
const exists = relations.some(r => r.from === chosen && r.to === alt && r.relationType === 'CHOSE_OVER');
if (!exists) {
relations.push({ from: chosen, to: alt, relationType: 'CHOSE_OVER' });
}
}
}
}
return { entityMap, relations };
}
function mergeEntity(map, entity) {
const existing = map.get(entity.name);
if (existing) {
const obsSet = new Set([...existing.observations, ...entity.observations]);
existing.observations = [...obsSet];
} else {
map.set(entity.name, { ...entity });
}
}
// ---------------------------------------------------------------------------
// Build Mermaid code
// ---------------------------------------------------------------------------
function buildMermaid(entityMap, relations, layout) {
const lines = [`graph ${layout}`];
for (const [, cfg] of Object.entries(ENTITY_TYPES)) {
lines.push(` classDef ${cfg.className} fill:${cfg.color},stroke:${darken(cfg.color)},color:#fff`);
}
lines.push('');
const groups = {};
for (const [name, ent] of entityMap) {
const t = ent.entityType || 'Pattern';
if (!groups[t]) groups[t] = [];
groups[t].push({ name, ...ent });
}
for (const [type, label] of Object.entries(SUBGRAPH_LABELS)) {
const items = groups[type];
if (!items || items.length === 0) continue;
lines.push(` subgraph ${label}`);
for (const ent of items) {
const id = makeNodeId(type, ent.name);
const className = ENTITY_TYPES[type]?.className || 'pattern';
lines.push(` ${id}["${escMermaid(truncateLabel(ent.name))}"]:::${className}`);
}
lines.push(' end');
lines.push('');
}
const edgeSet = new Set();
for (const rel of relations) {
const fromType = findEntityType(entityMap, rel.from);
const toType = findEntityType(entityMap, rel.to);
const fromId = makeNodeId(fromType, rel.from);
const toId = makeNodeId(toType, rel.to);
if (fromId === toId) continue;
const syntax = EDGE_SYNTAX[rel.relationType] || '-.->';
const edgeKey = `${fromId}-${rel.relationType}-${toId}`;
if (edgeSet.has(edgeKey)) continue;
edgeSet.add(edgeKey);
lines.push(` ${fromId} ${syntax}|${rel.relationType}| ${toId}`);
}
return lines.join('\n');
}
function findEntityType(entityMap, name) {
const ent = entityMap.get(name);
if (ent) return ent.entityType || 'Pattern';
return inferEntityType(name);
}
// ---------------------------------------------------------------------------
// Build stats HTML
// ---------------------------------------------------------------------------
function buildStatsHtml(entityMap, relations, queueDepth, records) {
const counts = {};
for (const [, ent] of entityMap) {
const t = ent.entityType || 'Pattern';
counts[t] = (counts[t] || 0) + 1;
}
const relCounts = {};
for (const r of relations) {
relCounts[r.relationType] = (relCounts[r.relationType] || 0) + 1;
}
const timestamps = records
.map(r => r.metadata?.timestamp)
.filter(Boolean)
.sort();
const timeSpan = timestamps.length > 0
? `${timestamps[0].slice(0, 10)} to ${timestamps[timestamps.length - 1].slice(0, 10)}`
: 'N/A';
const cards = [
{ title: 'Total Entities', value: entityMap.size, detail: Object.entries(counts).map(([k, v]) => `${k}: ${v}`).join(', ') },
{ title: 'Total Relations', value: relations.length, detail: Object.entries(relCounts).map(([k, v]) => `${k}: ${v}`).join(', ') },
{ title: 'Time Span', value: timeSpan, detail: `${records.length} decision records` },
{ title: 'Queue Depth', value: queueDepth, detail: 'Pending graph operations' },
];
const html = `<div class="stats-panel">\n` +
cards.map(c => ` <div class="stat-card">
<h3>${c.title}</h3>
<div class="value">${c.value}</div>
<div class="detail">${c.detail}</div>
</div>`).join('\n') +
'\n </div>';
return html;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
const args = parseArgs(process.argv);
const projectDir = getProjectDir();
const memoryDir = join(projectDir, '.claude', 'memory');
const decisionsPath = join(memoryDir, 'decisions.jsonl');
const queuePath = join(memoryDir, 'graph-queue.jsonl');
const outputPath = join(memoryDir, 'graph.html');
const templatePath = join(__dirname, 'graph-template.html');
let records = readJsonl(decisionsPath);
const queueRecords = readJsonl(queuePath);
if (records.length === 0) {
console.log('No memories stored yet. Use `/ork:remember` to start building your knowledge graph.');
process.exit(0);
}
if (args.category) {
records = records.filter(r => r.metadata?.category === args.category);
if (records.length === 0) {
console.log(`No decisions found for category '${args.category}'.`);
process.exit(0);
}
}
if (args.recent) {
records.sort((a, b) => (b.metadata?.timestamp || '').localeCompare(a.metadata?.timestamp || ''));
records = records.slice(0, args.recent);
}
const { entityMap, relations } = buildGraphModel(records);
let layout = args.layout;
if (entityMap.size > args.limit) {
const connectionCounts = new Map();
for (const [name] of entityMap) connectionCounts.set(name, 0);
for (const rel of relations) {
connectionCounts.set(rel.from, (connectionCounts.get(rel.from) || 0) + 1);
connectionCounts.set(rel.to, (connectionCounts.get(rel.to) || 0) + 1);
}
const sorted = [...connectionCounts.entries()].sort((a, b) => b[1] - a[1]);
const keep = new Set(sorted.slice(0, args.limit).map(([name]) => name));
for (const [name] of entityMap) {
if (!keep.has(name)) entityMap.delete(name);
}
layout = 'LR';
console.log(`Showing ${args.limit} of ${connectionCounts.size} entities (most connected).`);
}
const mermaidCode = buildMermaid(entityMap, relations, layout);
const statsHtml = buildStatsHtml(entityMap, relations, queueRecords.length, records);
if (!existsSync(templatePath)) {
console.error(`Template not found: ${templatePath}`);
process.exit(1);
}
let html = readFileSync(templatePath, 'utf-8');
const subtitle = args.category
? `Category: ${args.category} | ${records.length} records`
: `${records.length} records | ${entityMap.size} entities | ${relations.length} relations`;
html = html.replace(/\{\{TITLE\}\}/g, 'OrchestKit Knowledge Graph');
html = html.replace(/\{\{SUBTITLE\}\}/g, subtitle);
html = html.replace(/\{\{MERMAID_CODE\}\}/g, mermaidCode);
html = html.replace(/\{\{STATS_HTML\}\}/g, statsHtml);
if (!existsSync(dirname(outputPath))) {
mkdirSync(dirname(outputPath), { recursive: true });
}
writeFileSync(outputPath, html, 'utf-8');
console.log(`Graph written to ${outputPath}`);
console.log(`\nGraph Statistics:`);
console.log(`- Entities: ${entityMap.size}`);
console.log(`- Relations: ${relations.length}`);
console.log(`- Records: ${records.length}`);
console.log(`- Queue depth: ${queueRecords.length} pending operations`);
openInBrowser(outputPath);
}
// ---------------------------------------------------------------------------
// Entry point guard + exports for testability
// ---------------------------------------------------------------------------
const isMainModule = resolve(fileURLToPath(import.meta.url)) === resolve(process.argv[1] || '');
if (isMainModule) main();
export { main, buildGraphModel, buildMermaid, parseArgs };
#!/usr/bin/env node
/**
* render-playground.mjs - Interactive Knowledge Graph Playground
*
* Reads decisions, queue, and flow data to build a full interactive
* SPA with Graph, Decisions, Entities, and Stats tabs.
*
* Usage:
* node scripts/render-playground.mjs [--session <id>]
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
ENTITY_TYPES,
makeNodeId,
inferEntityType,
normalizeEntity,
normalizeRelation,
classifyEdge,
buildEntityNodeIdMap,
getProjectDir,
readJsonl,
openInBrowser,
} from './graph-utils.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
// ---------------------------------------------------------------------------
// CLI args
// ---------------------------------------------------------------------------
function parseArgs(argv) {
const args = { session: null };
for (let i = 2; i < argv.length; i++) {
if (argv[i] === '--session') args.session = argv[++i];
}
return args;
}
// ---------------------------------------------------------------------------
// Flow reading
// ---------------------------------------------------------------------------
function readFlows(memoryDir) {
const flowsDir = join(memoryDir, 'flows');
if (!existsSync(flowsDir)) return [];
const flows = [];
for (const file of readdirSync(flowsDir)) {
if (!file.endsWith('.json')) continue;
try {
const data = JSON.parse(readFileSync(join(flowsDir, file), 'utf-8'));
flows.push(data);
} catch { /* skip corrupt */ }
}
return flows;
}
// ---------------------------------------------------------------------------
// Build PlaygroundData
// ---------------------------------------------------------------------------
function buildPlaygroundData(records, queueRecords, flows) {
const entityMap = new Map();
const rawEdges = [];
for (const rec of records) {
const sessionId = rec.metadata?.session_id || 'unknown';
if (Array.isArray(rec.entities)) {
for (const raw of rec.entities) {
const entity = normalizeEntity(raw);
mergeEntity(entityMap, entity, sessionId, rec.id);
}
}
if (rec.content?.alternatives && Array.isArray(rec.content.alternatives)) {
for (const alt of rec.content.alternatives) {
mergeEntity(entityMap, {
name: alt,
entityType: inferEntityType(alt),
observations: ['Rejected alternative'],
}, sessionId, rec.id);
}
if (rec.entities?.length > 0) {
const chosen = normalizeEntity(rec.entities[0]).name;
for (const alt of rec.content.alternatives) {
const exists = rec.relations?.some(r => r.from === chosen && r.to === alt && r.relationType === 'CHOSE_OVER');
if (!exists) {
rawEdges.push({ from: chosen, to: alt, relationType: 'CHOSE_OVER', sessionId });
}
}
}
}
if (Array.isArray(rec.relations)) {
for (const rel of rec.relations) {
const norm = normalizeRelation(rel);
rawEdges.push({ ...norm, sessionId });
}
}
}
// Count connections per entity
for (const edge of rawEdges) {
const fromEnt = entityMap.get(edge.from);
const toEnt = entityMap.get(edge.to);
if (fromEnt) fromEnt.connectionCount++;
if (toEnt) toEnt.connectionCount++;
}
// Pre-compute entity name -> node ID map
const entityNodeIds = buildEntityNodeIdMap(entityMap);
// Build nodes array
const nodes = [];
for (const [name, ent] of entityMap) {
const entityType = ent.entityType || 'Pattern';
nodes.push({
id: makeNodeId(entityType, name),
name,
entityType,
color: (ENTITY_TYPES[entityType] || ENTITY_TYPES.Pattern).color,
observations: ent.observations,
sessionIds: [...ent.sessionIds],
sourceRecordIds: [...ent.sourceRecordIds],
connectionCount: ent.connectionCount,
});
}
// Build edges array (deduplicated)
const edgeSet = new Set();
const edges = [];
for (const edge of rawEdges) {
const fromType = entityMap.get(edge.from)?.entityType || inferEntityType(edge.from);
const toType = entityMap.get(edge.to)?.entityType || inferEntityType(edge.to);
const fromId = makeNodeId(fromType, edge.from);
const toId = makeNodeId(toType, edge.to);
if (fromId === toId) continue;
const key = `${fromId}-${edge.relationType}-${toId}`;
if (edgeSet.has(key)) continue;
edgeSet.add(key);
edges.push({
from: fromId,
to: toId,
relationType: edge.relationType,
edgeCategory: classifyEdge(edge.relationType),
sessionId: edge.sessionId,
});
}
// Build session index
const sessions = buildSessionIndex(records, flows);
// Build stats
const entityCountsByType = {};
for (const n of nodes) {
entityCountsByType[n.entityType] = (entityCountsByType[n.entityType] || 0) + 1;
}
const relationCountsByType = {};
for (const e of edges) {
relationCountsByType[e.relationType] = (relationCountsByType[e.relationType] || 0) + 1;
}
const decisionCountsByCategory = {};
for (const r of records) {
const cat = r.metadata?.category || 'general';
decisionCountsByCategory[cat] = (decisionCountsByCategory[cat] || 0) + 1;
}
const timestamps = records
.map(r => r.metadata?.timestamp)
.filter(Boolean)
.sort();
const stats = {
totalEntities: nodes.length,
totalRelations: edges.length,
totalDecisions: records.length,
totalSessions: sessions.length,
queueDepth: queueRecords.length,
entityCountsByType,
relationCountsByType,
decisionCountsByCategory,
timeSpan: {
from: timestamps[0] || '',
to: timestamps[timestamps.length - 1] || '',
},
};
return {
nodes,
edges,
decisions: records,
sessions,
stats,
entityNodeIds,
generatedAt: new Date().toISOString(),
};
}
function mergeEntity(map, entity, sessionId, recordId) {
const existing = map.get(entity.name);
if (existing) {
const obsSet = new Set([...existing.observations, ...entity.observations]);
existing.observations = [...obsSet];
existing.sessionIds.add(sessionId);
if (recordId) existing.sourceRecordIds.add(recordId);
} else {
map.set(entity.name, {
...entity,
sessionIds: new Set([sessionId]),
sourceRecordIds: new Set(recordId ? [recordId] : []),
connectionCount: 0,
});
}
}
function buildSessionIndex(records, flows) {
const sessionMap = new Map();
for (const rec of records) {
const sid = rec.metadata?.session_id;
if (!sid) continue;
if (!sessionMap.has(sid)) {
sessionMap.set(sid, {
id: sid,
startedAt: rec.metadata.timestamp || '',
decisionCount: 0,
entityNames: new Set(),
});
}
const s = sessionMap.get(sid);
s.decisionCount++;
if (rec.metadata.timestamp && (!s.startedAt || rec.metadata.timestamp < s.startedAt)) {
s.startedAt = rec.metadata.timestamp;
}
if (Array.isArray(rec.entities)) {
for (const e of rec.entities) {
const name = typeof e === 'string' ? e : e.name;
if (name) s.entityNames.add(name);
}
}
}
for (const flow of flows) {
const sid = flow.session_id;
if (!sid) continue;
if (!sessionMap.has(sid)) {
sessionMap.set(sid, {
id: sid,
startedAt: flow.started_at || '',
decisionCount: 0,
entityNames: new Set(),
});
}
const s = sessionMap.get(sid);
if (flow.started_at && (!s.startedAt || flow.started_at < s.startedAt)) {
s.startedAt = flow.started_at;
}
}
return [...sessionMap.entries()].map(([id, s]) => {
const date = s.startedAt ? new Date(s.startedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : 'Unknown';
return {
id,
label: `${id.slice(0, 8)} \u2014 ${date} (${s.decisionCount} decisions)`,
startedAt: s.startedAt,
decisionCount: s.decisionCount,
entityCount: s.entityNames.size,
};
}).sort((a, b) => (b.startedAt || '').localeCompare(a.startedAt || ''));
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
parseArgs(process.argv); // validates CLI flags
const projectDir = getProjectDir();
const memoryDir = join(projectDir, '.claude', 'memory');
const decisionsPath = join(memoryDir, 'decisions.jsonl');
const queuePath = join(memoryDir, 'graph-queue.jsonl');
const outputPath = join(memoryDir, 'playground.html');
const templatePath = join(__dirname, 'playground-template.html');
const records = readJsonl(decisionsPath);
const queueRecords = readJsonl(queuePath);
const flows = readFlows(memoryDir);
if (records.length === 0) {
console.log('No memories stored yet. Use `/ork:remember` to start building your knowledge graph.');
process.exit(0);
}
const data = buildPlaygroundData(records, queueRecords, flows);
console.log(`Playground data built:`);
console.log(` - ${data.nodes.length} entities`);
console.log(` - ${data.edges.length} relations`);
console.log(` - ${data.decisions.length} decisions`);
console.log(` - ${data.sessions.length} sessions`);
console.log(` - ${data.stats.queueDepth} queued operations`);
if (!existsSync(templatePath)) {
console.error(`Template not found: ${templatePath}`);
process.exit(1);
}
let html = readFileSync(templatePath, 'utf-8');
// Inline vis-network for offline use
const visNetworkPath = join(__dirname, 'vis-network.min.js');
if (existsSync(visNetworkPath)) {
const visNetworkCode = readFileSync(visNetworkPath, 'utf-8');
html = html.replace(
/<script src="https:\/\/unpkg\.com\/vis-network@[^"]+"><\/script>/,
`<script>/* vis-network 9.1.9 - inlined for offline use */\n${visNetworkCode}</script>`
);
console.log(' - vis-network inlined for offline use');
} else {
console.warn(' - vis-network.min.js not found, using CDN fallback');
}
// Inject data as JSON
const jsonBlob = JSON.stringify(data);
html = html.replace('{{PLAYGROUND_DATA}}', jsonBlob.replace(/</g, '\\u003c').replace(/>/g, '\\u003e'));
if (!existsSync(dirname(outputPath))) {
mkdirSync(dirname(outputPath), { recursive: true });
}
writeFileSync(outputPath, html, 'utf-8');
console.log(`\nPlayground written to ${outputPath}`);
openInBrowser(outputPath);
}
// ---------------------------------------------------------------------------
// Entry point guard + exports for testability
// ---------------------------------------------------------------------------
const isMainModule = resolve(fileURLToPath(import.meta.url)) === resolve(process.argv[1] || '');
if (isMainModule) main();
export { main, buildPlaygroundData, buildSessionIndex, parseArgs };
# Generated by OrchestKit Claude Plugin
# Created: 2026-05-20
#!/usr/bin/env python3
"""staleness_cron.py — nightly KG staleness sweep.
Closes orchestkit#1888. Designed to run from GH Actions cron — uses
`yg-mcp-core 0.3.0` to call memory MCP tools from a headless context
without spawning a second LLM.
Reads ALL memory MCP entities via `search_nodes(query="*")`, filters for
entries whose latest observation timestamp is older than the threshold,
and writes a Markdown staleness report sorted by staleness desc.
Pure parsing/report helpers live in `staleness_lib.py`.
Three exit-0 "skip" paths (none are errors):
- yg-mcp-core not importable
- memory MCP unreachable
- empty KG (writes empty report + exit 0)
Usage:
staleness_cron.py <reports-dir> [--threshold-days N] [--limit N]
Outputs:
<reports-dir>/memory-staleness-YYYY-MM-DD.md
<reports-dir>/memory-staleness-YYYY-MM-DD.json (machine-readable + handoff)
Exit codes:
0 success, auto-skip per signals, OR MCP/dep unreachable (WARN only)
1 pre-flight failure (missing args, reports dir invalid)
2 memory MCP returned an error AFTER probe succeeded (real error)
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from datetime import UTC, datetime
from pathlib import Path
# Make sibling module importable when run as a script.
sys.path.insert(0, str(Path(__file__).resolve().parent))
from staleness_lib import ( # noqa: E402
build_report_payload,
extract_entities,
render_markdown,
)
# Override at test time via env var.
DEFAULT_MCP_SERVER = os.environ.get("ORK_MEMORY_MCP_SERVER", "memory")
DEFAULT_THRESHOLD_DAYS = 30
DEFAULT_LIMIT = 50
SEARCH_LIMIT = 1000
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Nightly KG staleness sweep — writes a Markdown report.",
)
parser.add_argument(
"reports_dir",
type=Path,
help="Directory to write the report into (created if missing).",
)
parser.add_argument(
"--threshold-days",
type=int,
default=DEFAULT_THRESHOLD_DAYS,
help=f"Staleness cutoff in days (default: {DEFAULT_THRESHOLD_DAYS}).",
)
parser.add_argument(
"--limit",
type=int,
default=DEFAULT_LIMIT,
help=f"Max entries in the report (default: {DEFAULT_LIMIT}).",
)
return parser.parse_args(argv)
def _write_skip(handoff_path: Path, reason: str) -> None:
handoff_path.write_text(
json.dumps({"status": "skipped", "skip_reason": reason}, indent=2, sort_keys=True),
encoding="utf-8",
)
def main(argv: list[str] | None = None, *, now: datetime | None = None) -> int:
args = _parse_args(argv)
reports_dir: Path = args.reports_dir
threshold_days: int = args.threshold_days
limit: int = args.limit
now_dt = now or datetime.now(UTC)
if threshold_days < 0:
print("staleness_cron: --threshold-days must be >= 0", file=sys.stderr)
return 1
if limit <= 0:
print("staleness_cron: --limit must be > 0", file=sys.stderr)
return 1
try:
reports_dir.mkdir(parents=True, exist_ok=True)
except OSError as exc:
print(
f"staleness_cron: cannot create reports dir {reports_dir}: {exc}",
file=sys.stderr,
)
return 1
date_str = now_dt.strftime("%Y-%m-%d")
md_path = reports_dir / f"memory-staleness-{date_str}.md"
json_path = reports_dir / f"memory-staleness-{date_str}.json"
# MCP preflight — fail-soft on dep miss OR unreachable server.
try:
from mcp_core.client import ( # type: ignore[import-not-found]
McpToolError,
McpUnreachable,
call_tool,
probe,
)
except ImportError:
print(
"staleness_cron: WARN — yg-mcp-core not importable; skipping "
"(install yg-mcp-core>=0.3.0 from pypi.yonyon.ai to enable)"
)
_write_skip(json_path, "yg-mcp-core not importable")
return 0
server = DEFAULT_MCP_SERVER
if not probe(server):
print(f"staleness_cron: WARN — {server!r} MCP unreachable; skipping")
_write_skip(json_path, f"{server!r} MCP unreachable")
return 0
# Pull the KG.
print(f"staleness_cron: fetching nodes from {server!r} (limit={SEARCH_LIMIT})")
try:
result = call_tool(server, "search_nodes", {"query": "*", "limit": SEARCH_LIMIT})
except (McpUnreachable, McpToolError) as exc:
print(f"staleness_cron: ERROR — {type(exc).__name__}: {exc}", file=sys.stderr)
return 2
entities = extract_entities(result)
print(f"staleness_cron: {len(entities)} entities loaded")
payload = build_report_payload(entities, threshold_days=threshold_days, limit=limit, now=now_dt)
md_path.write_text(render_markdown(payload), encoding="utf-8")
json_path.write_text(
json.dumps(
{
"status": "fired",
"server": server,
"report_md_path": str(md_path),
**payload,
},
indent=2,
sort_keys=True,
),
encoding="utf-8",
)
print(
f"staleness_cron: ✓ wrote {md_path} "
f"({payload['stale_count']} stale / {payload['total_entities']} total)"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
# Generated by OrchestKit Claude Plugin
# Created: 2026-05-20
"""Pure functions powering staleness_cron.py — extracted so the CLI module
stays under the project's per-file LOC budget AND so the helpers are easy
to unit-test without subprocess.
No I/O, no globals. Functions:
- parse_iso_timestamp(s) -> datetime | None
- latest_observation_timestamp(entity) -> datetime | None
- is_stale(entity, now, threshold_days) -> (bool, age_days | None)
- extract_entities(result) -> list[dict]
- build_report_payload(entities, ...) -> dict
- render_markdown(payload) -> str
"""
from __future__ import annotations
import re
from datetime import UTC, datetime
from typing import Any
# Match ISO 8601 timestamps inside observation strings.
# Examples that match:
# "2026-05-19T13:24:00Z"
# "2026-05-19T13:24:00+00:00"
# "2026-05-19"
_ISO_DATE_RE = re.compile(
r"\b(\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2})?(?:Z|[+-]\d{2}:?\d{2})?)?)\b"
)
def parse_iso_timestamp(s: str) -> datetime | None:
"""Best-effort parse of an ISO-8601 timestamp string. Returns UTC datetime."""
candidate = s.strip().rstrip("Z")
if "T" not in candidate and " " in candidate:
candidate = candidate.replace(" ", "T", 1)
try:
dt = datetime.fromisoformat(candidate)
except ValueError:
try:
dt = datetime.fromisoformat(f"{candidate}T00:00:00")
except ValueError:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
return dt.astimezone(UTC)
def latest_observation_timestamp(entity: dict[str, Any]) -> datetime | None:
"""Find the most recent ISO timestamp across all observations.
Memory entities have `observations: list[str]`. There's no schema for
where timestamps live, so we scrape every ISO-8601-looking substring
and take the max.
"""
observations = entity.get("observations")
if not isinstance(observations, list):
return None
candidates: list[datetime] = []
for obs in observations:
if not isinstance(obs, str):
continue
for match in _ISO_DATE_RE.findall(obs):
parsed = parse_iso_timestamp(match)
if parsed is not None:
candidates.append(parsed)
if not candidates:
return None
return max(candidates)
def is_stale(
entity: dict[str, Any],
now: datetime,
threshold_days: int,
) -> tuple[bool, int | None]:
"""Return (is_stale, age_days). age_days is None when no timestamp found.
Entities with no parsable timestamp are TREATED AS STALE — operator
intervention is the right outcome.
"""
latest = latest_observation_timestamp(entity)
if latest is None:
return True, None
age = now - latest
return age.days >= threshold_days, age.days
def extract_entities(result: Any) -> list[dict[str, Any]]:
"""Pull the `entities` list out of a search_nodes result.
Memory MCP returns either a plain list, or a dict with `entities`/`nodes`.
Be defensive — the script must never crash on an unexpected shape.
"""
if isinstance(result, list):
return [e for e in result if isinstance(e, dict)]
if isinstance(result, dict):
for key in ("entities", "nodes", "result"):
entities = result.get(key)
if isinstance(entities, list):
return [e for e in entities if isinstance(e, dict)]
return []
def _entity_name(entity: dict[str, Any]) -> str:
name = entity.get("name")
return name if isinstance(name, str) else "<unnamed>"
def _entity_type(entity: dict[str, Any]) -> str:
et = entity.get("entityType")
return et if isinstance(et, str) else "<no-type>"
def _observations_count(entity: dict[str, Any]) -> int:
observations = entity.get("observations")
if isinstance(observations, list):
return len(observations)
return 0
def build_report_payload(
entities: list[dict[str, Any]],
*,
threshold_days: int,
limit: int,
now: datetime,
) -> dict[str, Any]:
"""Return a machine-readable payload covering the whole sweep."""
stale_entries: list[dict[str, Any]] = []
fresh_count = 0
for entity in entities:
stale, age_days = is_stale(entity, now, threshold_days)
if stale:
stale_entries.append(
{
"name": _entity_name(entity),
"entityType": _entity_type(entity),
"age_days": age_days,
"observations_count": _observations_count(entity),
}
)
else:
fresh_count += 1
# Sort: no-timestamp first (most-stale), then oldest → newest.
stale_entries.sort(
key=lambda e: (e["age_days"] is not None, -(e["age_days"] or 0)),
)
return {
"generated_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
"threshold_days": threshold_days,
"total_entities": len(entities),
"stale_count": len(stale_entries),
"fresh_count": fresh_count,
"top_stale": stale_entries[:limit],
}
def render_markdown(payload: dict[str, Any]) -> str:
"""Render a human-friendly Markdown report from the payload."""
lines: list[str] = []
lines.append(f"# Memory KG staleness report — {payload['generated_at']}")
lines.append("")
lines.append(
f"**{payload['stale_count']} stale** / "
f"{payload['fresh_count']} fresh / "
f"{payload['total_entities']} total "
f"(threshold: {payload['threshold_days']} days)"
)
lines.append("")
if not payload["top_stale"]:
lines.append("No stale entities. Knowledge graph is healthy.")
return "\n".join(lines) + "\n"
lines.append("## Top stale entries")
lines.append("")
lines.append("| # | Name | Type | Age | Observations |")
lines.append("|---|------|------|-----|--------------|")
for i, entry in enumerate(payload["top_stale"], 1):
age = "no timestamp" if entry["age_days"] is None else f"{entry['age_days']}d"
lines.append(
f"| {i} | `{entry['name']}` | {entry['entityType']} | "
f"{age} | {entry['observations_count']} |"
)
lines.append("")
lines.append("---")
lines.append("")
lines.append("Suggested actions:")
lines.append(
"- **no timestamp** entries: backfill with a `last_read` observation, "
"or prune if obsolete"
)
lines.append("- entries > 90d: review whether the memory is still load-bearing")
lines.append("- entries > 180d: strong candidate for archival / prune")
return "\n".join(lines) + "\n"
{
"skill": "memory",
"version": "1.0.0",
"testCases": [
{
"id": "basic-orkmemory-search-pagination",
"rule": "",
"query": "/ork:memory search pagination",
"expectedBehavior": [
"Claude invokes mcp__memory__search_nodes with query 'pagination'",
"Returns matching entities and relations from the knowledge graph",
"Formats results with entity names, types, and observations"
]
},
{
"id": "negative-orkmemory",
"rule": "",
"query": "/ork:memory",
"expectedBehavior": [
"Claude detects no subcommand was provided",
"Uses AskUserQuestion to present subcommand options: search, load, history, viz, status",
"Does NOT guess or auto-select a subcommand"
]
},
{
"id": "negative-store-the-decision-that",
"rule": "",
"query": "Store the decision that we chose PostgreSQL over MongoDB for our backend",
"expectedBehavior": [
"Claude does NOT invoke the memory skill (memory is read-side only)",
"Routes to the remember skill instead for write operations",
"Does not call mcp__memory__search_nodes or mcp__memory__read_graph"
]
},
{
"id": "deduplication-strategy",
"rule": "deduplication-strategy",
"query": "When loading memories from the knowledge graph, how do I prevent duplicate entries from wasting context tokens?",
"expectedBehavior": [
"Prefers Edit over Write when updating memory files to preserve existing content",
"Uses anchor-based insertion under specific section headers to avoid duplicate entries",
"Verifies target section header exists before inserting new memory content into the file",
"Reads the file after editing to confirm the edit applied correctly without duplication"
]
},
{
"id": "entity-extraction-patterns",
"rule": "entity-extraction-patterns",
"query": "What entity types does the memory knowledge graph use and how should I categorize new entries?",
"expectedBehavior": [
"Categorizes entities into types including Technology, Agent, Pattern, Decision, and Project",
"Associates typical observations with each entity type such as version and use case for Technology",
"Distinguishes Pattern from AntiPattern entities based on success or failure outcomes",
"Includes Constraint and Preference entity types with source, severity, and scope attributes"
]
}
]
}