@jerryjiao/knowflow 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ KnowFlow Graph Relation Labeler
4
+ Infers semantic relation types from wikilink context and updates graph.json
5
+ """
6
+
7
+ import argparse
8
+ import re, json, os
9
+ from pathlib import Path
10
+ from collections import defaultdict
11
+
12
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
13
+ DEFAULT_WIKI_DIR = os.environ.get("KNOWFLOW_WIKI_DIR", str(PROJECT_ROOT / "wiki"))
14
+ DEFAULT_GRAPH_OUTPUT = Path(os.environ.get("KNOWFLOW_GRAPH_OUTPUT", PROJECT_ROOT / "graph" / "graph.html"))
15
+ DEFAULT_GRAPH_JSON = os.environ.get("KNOWFLOW_GRAPH_JSON", str(DEFAULT_GRAPH_OUTPUT.with_suffix(".json")))
16
+
17
+ # ── Relation inference rules ──────────────────────────────
18
+
19
+ # Category-based default relations
20
+ CATEGORY_RELATIONS = {
21
+ # source → entity: "cites" or "describes"
22
+ ("sources", "entities"): "描述",
23
+ ("sources", "concepts"): "阐述",
24
+ ("sources", "comparisons"): "参考",
25
+ ("sources", "sources"): "相关",
26
+ # entity → concept: "implements" or "exemplifies"
27
+ ("entities", "concepts"): "体现",
28
+ ("entities", "entities"): "关联",
29
+ ("entities", "sources"): "来源",
30
+ # concept → entity: "applies_to" or "governs"
31
+ ("concepts", "entities"): "适用于",
32
+ ("concepts", "concepts"): "相关",
33
+ ("concepts", "sources"): "引用",
34
+ # comparison → others
35
+ ("comparisons", "entities"): "对比",
36
+ ("comparisons", "concepts"): "评估",
37
+ ("comparisons", "sources"): "依据",
38
+ }
39
+
40
+ def detect_category(filepath):
41
+ parts = Path(filepath).parts
42
+ if len(parts) >= 2:
43
+ parent = parts[-2]
44
+ if parent in ["sources", "entities", "concepts", "comparisons"]:
45
+ return parent
46
+ stem = Path(filepath).stem.lower()
47
+ if stem in ("index", "log", "overview"):
48
+ return "_special"
49
+ return "sources"
50
+
51
+ # Context-based relation refinement
52
+ CONTEXT_PATTERNS = [
53
+ (r'(来源|来自|基于|引自|参考|参见)', "来源"),
54
+ (r'(相关|关联|类似|同类|同属)', "关联"),
55
+ (r'(对比|比较|vs\.?|versus)', "对比"),
56
+ (r'(实现|使用|采用|应用|工具)', "使用"),
57
+ (r'(属于|归类|分类|类型)', "属于"),
58
+ (r'(替代|取代|替换)', "替代"),
59
+ (r'(衍生|扩展|进化|发展)', "衍生"),
60
+ (r'(姊妹|兄弟|姐妹项目)', "姊妹"),
61
+ (r'(推荐|首选|建议)', "推荐"),
62
+ (r'(创建者|作者|开发者|创始人)', "创建"),
63
+ (r'(支持|兼容|集成|接入)', "支持"),
64
+ (r'(竞争对手|竞品|对手)', "竞争"),
65
+ ]
66
+
67
+ def infer_relation(from_path, to_path, from_content, all_categories):
68
+ """Infer the most appropriate relation label for an edge."""
69
+ from_cat = detect_category(from_path)
70
+ to_cat = detect_category(to_path)
71
+
72
+ # 1. Try category-based default
73
+ default = CATEGORY_RELATIONS.get((from_cat, to_cat))
74
+
75
+ # 2. Refine based on context around the wikilink
76
+ link_target = Path(to_path).stem
77
+
78
+ # Find the wikilink in content and check surrounding text
79
+ pattern = r'\[\[' + re.escape(link_target) + r'([^\]]*)\]\]'
80
+ matches = list(re.finditer(pattern, from_content))
81
+
82
+ if matches:
83
+ # Check context around each match (50 chars before/after)
84
+ for m in matches:
85
+ start = max(0, m.start() - 50)
86
+ end = min(len(from_content), m.end() + 50)
87
+ context = from_content[start:end]
88
+ for pat, rel in CONTEXT_PATTERNS:
89
+ if re.search(pat, context):
90
+ return rel
91
+
92
+ # 3. Fall back to category default or generic
93
+ return default or "链接"
94
+
95
+
96
+ def main():
97
+ parser = argparse.ArgumentParser(description="Add semantic labels to a KnowFlow graph")
98
+ parser.add_argument("--wiki-dir", default=DEFAULT_WIKI_DIR, help="Wiki directory")
99
+ parser.add_argument("--graph-json", default=DEFAULT_GRAPH_JSON, help="Graph JSON file")
100
+ args = parser.parse_args()
101
+ wiki_dir = Path(args.wiki_dir).resolve()
102
+ graph_json = Path(args.graph_json).resolve()
103
+
104
+ print("🏷️ 开始为图谱边添加关系标签...")
105
+
106
+ # Load existing graph
107
+ with graph_json.open('r', encoding='utf-8') as f:
108
+ graph = json.load(f)
109
+
110
+ # Read all wiki contents for context analysis
111
+ wiki_path = wiki_dir
112
+ contents = {}
113
+ for md_file in wiki_path.rglob('*.md'):
114
+ rel = str(md_file.relative_to(wiki_path))
115
+ try:
116
+ contents[rel] = md_file.read_text(encoding='utf-8')
117
+ except:
118
+ pass
119
+
120
+ # Build category cache
121
+ categories = {p: detect_category(p) for p in contents}
122
+
123
+ # Process each edge
124
+ updated = 0
125
+ relation_counts = defaultdict(int)
126
+
127
+ for edge in graph.get('edges', []):
128
+ from_id = edge.get('from', '')
129
+ to_id = edge.get('to', '')
130
+
131
+ if not from_id or not to_id:
132
+ continue
133
+
134
+ from_content = contents.get(from_id, '')
135
+ relation = infer_relation(from_id, to_id, from_content, categories)
136
+
137
+ edge['relation'] = relation
138
+ edge['title'] = f"{relation}" # tooltip
139
+ relation_counts[relation] += 1
140
+ updated += 1
141
+
142
+ # Also add relation info to node titles for better tooltips
143
+ for node in graph.get('nodes', []):
144
+ nid = node.get('id', '')
145
+ cat = categories.get(nid, detect_category(nid))
146
+ # Add Chinese category label
147
+ cat_labels = {
148
+ 'sources': '📥 来源', 'entities': '👤 实体',
149
+ 'concepts': '💡 概念', 'comparisons': '⚖️ 对比',
150
+ '_special': '⭐ 索引'
151
+ }
152
+ existing_title = node.get('title', '')
153
+ if cat_labels.get(cat) and cat_labels[cat] not in existing_title:
154
+ node['category_label'] = cat_labels[cat]
155
+
156
+ # Save updated graph
157
+ with graph_json.open('w', encoding='utf-8') as f:
158
+ json.dump(graph, f, ensure_ascii=False, indent=2)
159
+
160
+ print(f"✅ 完成!更新了 {updated} 条边的关系标签")
161
+ print(f"\n📊 关系分布:")
162
+ for rel, count in sorted(relation_counts.items(), key=lambda x: -x[1]):
163
+ bar = "█" * min(count, 40)
164
+ print(f" {rel:8s} {count:4d} {bar}")
165
+
166
+ # Stats
167
+ total_edges = len(graph.get('edges', []))
168
+ total_nodes = len(graph.get('nodes', []))
169
+
170
+ # Count isolated nodes (degree 0)
171
+ connected = set()
172
+ for e in graph.get('edges', []):
173
+ connected.add(e.get('from'))
174
+ connected.add(e.get('to'))
175
+ isolated = [n for n in graph.get('nodes', []) if n.get('id') not in connected]
176
+
177
+ print(f"\n📈 图谱统计:")
178
+ print(f" 总节点: {total_nodes}")
179
+ print(f" 总边数: {total_edges}")
180
+ print(f" 连接节点: {len(connected)}")
181
+ print(f" 孤立节点: {len(isolated)} ({len(isolated)*100//max(total_nodes,1)}%)")
182
+
183
+ if isolated:
184
+ print(f"\n⚠️ 孤立节点列表:")
185
+ for n in isolated[:10]:
186
+ print(f" - {n.get('label', n.get('id'))} ({n.get('id')})")
187
+ if len(isolated) > 10:
188
+ print(f" ... 还有 {len(isolated)-10} 个")
189
+
190
+ if __name__ == '__main__':
191
+ main()
@@ -0,0 +1,162 @@
1
+ #!/bin/bash
2
+ # knowflow ingest pipeline — 自动识别来源并提取全文
3
+ # Usage: bash ingest.sh <url_or_content> [source_type]
4
+ # Source type: auto | twitter | xiaohongshu | wechat | web | youtube | text
5
+
6
+ set -euo pipefail
7
+
8
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
9
+ WIKI_ROOT="${KNOWFLOW_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}"
10
+ RAW_DIR="${KNOWFLOW_RAW_DIR:-$WIKI_ROOT/raw}"
11
+ TIMESTAMP=$(date +%Y-%m-%d-%H%M)
12
+
13
+ URL="${1:-}"
14
+ SOURCE_TYPE="${2:-auto}"
15
+
16
+ if [ -z "$URL" ]; then
17
+ echo "❌ Usage: bash ingest.sh <url_or_text> [source_type]"
18
+ exit 1
19
+ fi
20
+
21
+ # Auto-detect source type from URL
22
+ detect_source() {
23
+ local url="$1"
24
+ if echo "$url" | grep -qi 'x\.com\|twitter\.com'; then
25
+ echo "twitter"
26
+ elif echo "$url" | grep -qi 'xiaohongshu\|xhslink\|xhscdn'; then
27
+ echo "xiaohongshu"
28
+ elif echo "$url" | grep -qi 'mp\.weixin\|weixin.*article\|wx'; then
29
+ echo "wechat"
30
+ elif echo "$url" | grep -qi 'youtube\|youtu\.be'; then
31
+ echo "youtube"
32
+ elif echo "$url" | grep -qi '^https\?://'; then
33
+ echo "web"
34
+ else
35
+ echo "text"
36
+ fi
37
+ }
38
+
39
+ [ "$SOURCE_TYPE" = "auto" ] && SOURCE_TYPE=$(detect_source "$URL")
40
+
41
+ # ── URL Sanitization ─────────────────────────────────────
42
+ validate_url() {
43
+ local url="$1"
44
+ # Arguments are passed without a shell; keep normal query strings intact.
45
+ if ! echo "$url" | grep -qE '^https?://[^[:space:]]+$'; then
46
+ echo "❌ Invalid URL: $url" >&2
47
+ return 1
48
+ fi
49
+ }
50
+ if [ "$SOURCE_TYPE" != "text" ]; then
51
+ validate_url "$URL" || exit 1
52
+ fi
53
+
54
+ mkdir -p "$RAW_DIR"/{twitter,xiaohongshu,wechat,web}
55
+
56
+ OUTPUT_FILE=""
57
+ EXTRACT_METHOD=""
58
+
59
+ case "$SOURCE_TYPE" in
60
+ twitter)
61
+ echo "🐦 检测到 Twitter/X 链接..."
62
+ OUTPUT_FILE="$RAW_DIR/twitter/$TIMESTAMP-tweet.md"
63
+ # Extract tweet via twitter CLI or Jina
64
+ if command -v twitter &>/dev/null; then
65
+ TWEET_ID=$(echo "$URL" | grep -oE '[0-9]{15,}' | head -1)
66
+ if [ -n "$TWEET_ID" ]; then
67
+ twitter get "$TWEET_ID" 2>/dev/null > "$OUTPUT_FILE" || \
68
+ curl -sL "https://r.jina.ai/$URL" > "$OUTPUT_FILE" 2>/dev/null
69
+ else
70
+ curl -sL "https://r.jina.ai/$URL" > "$OUTPUT_FILE" 2>/dev/null
71
+ fi
72
+ else
73
+ curl -sL "https://r.jina.ai/$URL" > "$OUTPUT_FILE" 2>/dev/null
74
+ fi
75
+ EXTRACT_METHOD="jina_reader"
76
+ ;;
77
+
78
+ xiaohongshu)
79
+ echo "📕 检测到小红书链接..."
80
+ OUTPUT_FILE="$RAW_DIR/xiaohongshu/$TIMESTAMP-xhs.md"
81
+ # Use agent-browser for xiaohongshu (needs JS rendering)
82
+ if command -v agent-browser &>/dev/null; then
83
+ # Try Jina first, fallback to agent-browser
84
+ HTTP_CODE=$(curl -sL -o "$OUTPUT_FILE" -w "%{http_code}" --max-time 15 "https://r.jina.ai/$URL" 2>/dev/null) || true
85
+ if [ "$HTTP_CODE" != "200" ] || [ ! -s "$OUTPUT_FILE" ]; then
86
+ echo "> ⚠️ Jina 提取失败,小红书可能需要浏览器渲染,已保存原始链接" > "$OUTPUT_FILE"
87
+ echo "" >> "$OUTPUT_FILE"
88
+ echo "**原始链接**: $URL" >> "$OUTPUT_FILE"
89
+ echo "" >> "$OUTPUT_FILE"
90
+ echo "> 💡 提示: 小红书内容需要通过 agent-browser 渲染提取" >> "$OUTPUT_FILE"
91
+ fi
92
+ else
93
+ curl -sL "https://r.jina.ai/$URL" > "$OUTPUT_FILE" 2>/dev/null || echo "**原始链接**: $URL" > "$OUTPUT_FILE"
94
+ fi
95
+ EXTRACT_METHOD="jina_reader"
96
+ ;;
97
+
98
+ wechat)
99
+ echo "💬 检测到微信公众号链接..."
100
+ OUTPUT_FILE="$RAW_DIR/wechat/$TIMESTAMP-wechat.md"
101
+ curl -sL --max-time 20 "https://r.jina.ai/$URL" > "$OUTPUT_FILE" 2>/dev/null
102
+ EXTRACT_METHOD="jina_reader"
103
+ ;;
104
+
105
+ youtube)
106
+ echo "▶️ 检测到 YouTube 链接..."
107
+ OUTPUT_FILE="$RAW_DIR/web/$TIMESTAMP-youtube.md"
108
+ if command -v yt-dlp &>/dev/null; then
109
+ # Get video info + transcript
110
+ echo "# YouTube Video" > "$OUTPUT_FILE"
111
+ echo "" >> "$OUTPUT_FILE"
112
+ echo "**URL**: $URL" >> "$OUTPUT_FILE"
113
+ echo "" >> "$OUTPUT_FILE"
114
+ echo "## Video Info" >> "$OUTPUT_FILE"
115
+ yt-dlp --print title --print description --print duration_string --no-download "$URL" >> "$OUTPUT_FILE" 2>/dev/null || true
116
+ echo "" >> "$OUTPUT_FILE"
117
+ echo "## Transcript / Subtitles" >> "$OUTPUT_FILE"
118
+ yt-dlp --write-sub --sub-langs "zh,en" --skip-download -o "/tmp/wiki-yt-sub" "$URL" 2>/dev/null && \
119
+ cat /tmp/wiki-yt-sub*.vtt 2>/dev/null | sed 's/<[^>]*>//g' | grep -v '^$' | head -200 >> "$OUTPUT_FILE" || \
120
+ echo "(无字幕可用)" >> "$OUTPUT_FILE"
121
+ rm -f /tmp/wiki-yt-sub* 2>/dev/null || true
122
+ else
123
+ curl -sL "https://r.jina.ai/$URL" > "$OUTPUT_FILE" 2>/dev/null
124
+ fi
125
+ EXTRACT_METHOD="yt-dlp+jina"
126
+ ;;
127
+
128
+ web)
129
+ echo "🌐 检测到网页链接..."
130
+ # Determine subdirectory by domain
131
+ DOMAIN=$(echo "$URL" | sed 's|https\?://||' | cut -d'/' -f1)
132
+ OUTPUT_FILE="$RAW_DIR/web/$TIMESTAMP-web-${DOMAIN%%.*}.md"
133
+ curl -sL --max-time 20 "https://r.jina.ai/$URL" > "$OUTPUT_FILE" 2>/dev/null
134
+ EXTRACT_METHOD="jina_reader"
135
+ ;;
136
+
137
+ text)
138
+ echo "📝 纯文本内容..."
139
+ OUTPUT_FILE="$RAW_DIR/web/$TIMESTAMP-text.md"
140
+ echo "$URL" > "$OUTPUT_FILE"
141
+ EXTRACT_METHOD="direct"
142
+ ;;
143
+ esac
144
+
145
+ # Verify output
146
+ if [ -f "$OUTPUT_FILE" ] && [ -s "$OUTPUT_FILE" ]; then
147
+ SIZE=$(wc -c < "$OUTPUT_FILE" | tr -d ' ')
148
+ LINES=$(wc -l < "$OUTPUT_FILE" | tr -d ' ')
149
+ echo ""
150
+ echo "✅ 提取完成:"
151
+ echo " 📄 文件: $OUTPUT_FILE"
152
+ echo " 📏 大小: ${SIZE} bytes (${LINES} 行)"
153
+ echo " 🔧 方法: $EXTRACT_METHOD"
154
+ echo " 🏷️ 类型: $SOURCE_TYPE"
155
+ echo ""
156
+ echo "→ 下一步: 告诉 Agent 执行 ingest 处理此文件"
157
+ else
158
+ echo ""
159
+ echo "⚠️ 提取可能失败(文件为空或不存在)"
160
+ echo " 原始链接: $URL"
161
+ exit 1
162
+ fi
@@ -0,0 +1,73 @@
1
+ #!/bin/bash
2
+ # knowflow pipeline — automated 5-step wiki processing pipeline
3
+ # Usage: bash scripts/pipeline.sh [--dry-run] [--step=N]
4
+ #
5
+ # Steps:
6
+ # 1. bookmark_sync.sh — sync X/Twitter bookmarks
7
+ # 2. detect new raw/ files — compare .ingest-state.json vs raw/
8
+ # 3. ingest new files — run ingest.sh for each new file
9
+ # 4. vector-store build — incremental vector index (only if new pages)
10
+ # 5. wiki-health.sh — broken links, empty files, orphan pages
11
+ #
12
+ # Options:
13
+ # --dry-run Print each step without executing
14
+ # --step=N Run only step N (1-5)
15
+
16
+ set -euo pipefail
17
+
18
+ # ── Temp file cleanup ─────────────────────────────────────
19
+ _TEMP_FILES=()
20
+ cleanup() { rm -f "${_TEMP_FILES[@]:-}" 2>/dev/null || true; }
21
+ trap cleanup EXIT INT TERM
22
+
23
+ # ── Config ──────────────────────────────────────────────
24
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
25
+ WIKI_ROOT="${KNOWFLOW_ROOT:-${WIKI_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}}"
26
+ STATE_FILE="$WIKI_ROOT/.ingest-state.json"
27
+ RAW_DIR="${KNOWFLOW_RAW_DIR:-$WIKI_ROOT/raw}"
28
+ WIKI_DIR="${KNOWFLOW_WIKI_DIR:-$WIKI_ROOT/wiki}"
29
+ LOCK_FILE="$WIKI_ROOT/.pipeline.lock"
30
+ TIMESTAMP=$(date +%Y-%m-%d-%H%M)
31
+
32
+ DRY_RUN=false
33
+ RUN_STEP=0
34
+
35
+ # ── Parse args ──────────────────────────────────────────
36
+ for arg in "$@"; do
37
+ case "$arg" in
38
+ --dry-run) DRY_RUN=true ;;
39
+ --step=*) RUN_STEP="${arg#--step=}" ;;
40
+ *) echo "Unknown option: $arg" >&2; exit 1 ;;
41
+ esac
42
+ done
43
+
44
+ # ── Helpers ─────────────────────────────────────────────
45
+ step_header() {
46
+ local step_num="$1"
47
+ local step_name="$2"
48
+ echo ""
49
+ echo "━━━ Step $step_num: $step_name ━━━━━━━━━━━━━━━━━━━━━━━"
50
+ }
51
+
52
+ run_cmd() {
53
+ if $DRY_RUN; then
54
+ echo "[DRY RUN] $*"
55
+ else
56
+ "$@"
57
+ fi
58
+ }
59
+
60
+ should_run() {
61
+ local step="$1"
62
+ [ "$RUN_STEP" -eq 0 ] || [ "$RUN_STEP" -eq "$step" ]
63
+ }
64
+
65
+ # ── Summary tracking ────────────────────────────────────
66
+ STEP_RESULTS=()
67
+ HEALTH_EXIT=0
68
+ HEALTH_SCORE="UNKNOWN"
69
+ NEW_FILES_COUNT=0
70
+
71
+ if [ "${HEALTH_EXIT:-0}" -ne 0 ] && ! ${DRY_RUN:-false}; then
72
+ exit 1
73
+ fi
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * knowflow tags — build tag hub pages from [[tag/<name>]] links.
4
+ *
5
+ * Scans every Markdown page in the wiki for `[[tag/<name>]]` links and
6
+ * (re)generates `tag/<name>.md` hub pages that link back to every page
7
+ * carrying the tag. Hub pages older tags that no longer appear anywhere
8
+ * are removed, so re-running the command is fully idempotent.
9
+ *
10
+ * Environment:
11
+ * KNOWFLOW_WIKI_DIR — wiki root directory (set by the CLI)
12
+ */
13
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+
16
+ const WIKI_DIR = process.env.KNOWFLOW_WIKI_DIR;
17
+ if (!WIKI_DIR || !existsSync(WIKI_DIR)) {
18
+ console.error('knowflow tags: KNOWFLOW_WIKI_DIR is not set or missing');
19
+ process.exit(1);
20
+ }
21
+
22
+ const TAG_DIR = join(WIKI_DIR, 'tag');
23
+ const TAG_LINK = /\[\[tag\/([^\]|]+)(?:\|[^\]]*)?\]\]/g;
24
+
25
+ function* walkMd(dir) {
26
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
27
+ if (entry.name.startsWith('.')) continue;
28
+ const path = join(dir, entry.name);
29
+ if (entry.isDirectory()) yield* walkMd(path);
30
+ else if (entry.name.endsWith('.md')) yield path;
31
+ }
32
+ }
33
+
34
+ const tagMap = new Map();
35
+ for (const file of walkMd(WIKI_DIR)) {
36
+ const rel = file.slice(WIKI_DIR.length + 1);
37
+ if (rel.startsWith('tag/')) continue;
38
+ const text = readFileSync(file, 'utf8');
39
+ for (const match of text.matchAll(TAG_LINK)) {
40
+ const tag = match[1].trim();
41
+ if (!tag) continue;
42
+ if (!tagMap.has(tag)) tagMap.set(tag, new Set());
43
+ tagMap.get(tag).add(rel.replace(/\.md$/, ''));
44
+ }
45
+ }
46
+
47
+ mkdirSync(TAG_DIR, { recursive: true });
48
+
49
+ let written = 0;
50
+ for (const [tag, pages] of [...tagMap.entries()].sort((a, b) => b[1].size - a[1].size)) {
51
+ const pageList = [...pages].sort();
52
+ const content = [
53
+ `# Tag: ${tag}`,
54
+ '',
55
+ `> Auto-generated by \`knowflow tags\` — index of pages linking to [[tag/${tag}]]. Re-run the command to refresh.`,
56
+ '',
57
+ `## Pages (${pageList.length})`,
58
+ '',
59
+ ...pageList.map(page => `- [[${page}]]`),
60
+ '',
61
+ ].join('\n');
62
+ writeFileSync(join(TAG_DIR, `${tag}.md`), content, 'utf8');
63
+ written += 1;
64
+ }
65
+
66
+ let removed = 0;
67
+ for (const entry of existsSync(TAG_DIR) ? readdirSync(TAG_DIR) : []) {
68
+ if (!entry.endsWith('.md')) continue;
69
+ if (!tagMap.has(entry.replace(/\.md$/, ''))) {
70
+ rmSync(join(TAG_DIR, entry));
71
+ removed += 1;
72
+ }
73
+ }
74
+
75
+ console.log(`tags: ${written} hub page(s) under tag/ (${removed} stale removed)`);