@easysociable/cli 0.1.7 → 0.1.9

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.
Files changed (93) hide show
  1. package/README.md +29 -9
  2. package/dist/easysociable.js +111 -100
  3. package/dist/easysociable.js.map +1 -1
  4. package/package.json +2 -2
  5. package/scripts/engine/migrate-split-vault.py +75 -0
  6. package/skills/ONBOARDING.md +19 -4
  7. package/skills/easysociable-content/SKILL.md +25 -9
  8. package/content-engine/scripts/fixture.test.mjs +0 -24
  9. package/content-engine/scripts/starter.mjs +0 -58
  10. package/content-engine/scripts/validate.test.mjs +0 -101
  11. package/content-engine/scripts/workbench.mjs +0 -140
  12. package/content-engine/starter/CLAUDE.md +0 -908
  13. package/content-engine/starter/accounts/README.md +0 -9
  14. package/content-engine/starter/accounts/_index.md +0 -31
  15. package/content-engine/starter/accounts/overrides/README.md +0 -3
  16. package/content-engine/starter/engine.json +0 -23
  17. package/content-engine/starter/exports/README.md +0 -28
  18. package/content-engine/starter/hits/README.md +0 -5
  19. package/content-engine/starter/hits/_index.md +0 -30
  20. package/content-engine/starter/hits/_template.md +0 -46
  21. package/content-engine/starter/hits/entries/README.md +0 -3
  22. package/content-engine/starter/inbox/README.md +0 -9
  23. package/content-engine/starter/inbox/_index.md +0 -24
  24. package/content-engine/starter/inbox/entries/README.md +0 -3
  25. package/content-engine/starter/needs/README.md +0 -38
  26. package/content-engine/starter/needs/_index.md +0 -31
  27. package/content-engine/starter/needs/_template.md +0 -24
  28. package/content-engine/starter/needs/entries/README.md +0 -5
  29. package/content-engine/starter/platforms/README.md +0 -19
  30. package/content-engine/starter/platforms/ig.md +0 -69
  31. package/content-engine/starter/platforms/instagram.md +0 -5
  32. package/content-engine/starter/platforms/linkedin.md +0 -72
  33. package/content-engine/starter/platforms/threads.md +0 -69
  34. package/content-engine/starter/platforms/tiktok.md +0 -71
  35. package/content-engine/starter/platforms/x.md +0 -78
  36. package/content-engine/starter/platforms/xiaohongshu.md +0 -19
  37. package/content-engine/starter/products/README.md +0 -38
  38. package/content-engine/starter/products/_index.md +0 -25
  39. package/content-engine/starter/products/_template.md +0 -56
  40. package/content-engine/starter/products/entries/P-001-easysociable.md +0 -150
  41. package/content-engine/starter/products/entries/P-002-content-engine.md +0 -136
  42. package/content-engine/starter/profiles/_index.md +0 -16
  43. package/content-engine/starter/profiles/default/README.md +0 -17
  44. package/content-engine/starter/profiles/default/audience.md +0 -38
  45. package/content-engine/starter/profiles/default/boundaries.md +0 -14
  46. package/content-engine/starter/profiles/default/expression.md +0 -18
  47. package/content-engine/starter/profiles/default/handles.md +0 -8
  48. package/content-engine/starter/profiles/default/keywords.md +0 -31
  49. package/content-engine/starter/profiles/default/lanes.md +0 -12
  50. package/content-engine/starter/profiles/default/pillars.md +0 -33
  51. package/content-engine/starter/profiles/default/stories.md +0 -15
  52. package/content-engine/starter/profiles/default/voice.md +0 -38
  53. package/content-engine/starter/published/_index.md +0 -20
  54. package/content-engine/starter/raw/README.md +0 -4
  55. package/content-engine/starter/raw/_index.md +0 -12
  56. package/content-engine/starter/recommendations/README.md +0 -21
  57. package/content-engine/starter/recommendations/_index.md +0 -21
  58. package/content-engine/starter/recommendations/_template.md +0 -44
  59. package/content-engine/starter/runs/README.md +0 -22
  60. package/content-engine/starter/runs/_index.md +0 -41
  61. package/content-engine/starter/runs/_template/brief.md +0 -76
  62. package/content-engine/starter/runs/_template/draft.md +0 -11
  63. package/content-engine/starter/runs/_template/editor.md +0 -18
  64. package/content-engine/starter/runs/_template/feedback.md +0 -15
  65. package/content-engine/starter/runs/_template/idea.md +0 -27
  66. package/content-engine/starter/runs/_template/pack.md +0 -55
  67. package/content-engine/starter/runs/_template/packet.md +0 -31
  68. package/content-engine/starter/runs/_template/rubric.md +0 -19
  69. package/content-engine/starter/swipe/_index.md +0 -27
  70. package/content-engine/starter/swipe/_template.md +0 -36
  71. package/content-engine/starter/topics/README.md +0 -21
  72. package/content-engine/starter/topics/_index.md +0 -51
  73. package/content-engine/starter/topics/_template-item.md +0 -106
  74. package/content-engine/starter/topics/items/README.md +0 -4
  75. package/content-engine/starter/topics/scoring.md +0 -255
  76. package/content-engine/starter/wiki/README.md +0 -13
  77. package/content-engine/starter/wiki/atoms/_index.md +0 -23
  78. package/content-engine/starter/wiki/atoms/_template.md +0 -31
  79. package/content-engine/starter/wiki/claims/_index.md +0 -26
  80. package/content-engine/starter/wiki/claims/_template.md +0 -38
  81. package/content-engine/starter/workbench/README.md +0 -17
  82. package/content-engine/workbench/__pycache__/build.cpython-310.pyc +0 -0
  83. package/content-engine/workbench/build.py +0 -1467
  84. package/content-engine/workbench/serve.py +0 -53
  85. package/content-engine/workbench/template.html +0 -2686
  86. /package/{content-engine/scripts → scripts/engine}/contracts.mjs +0 -0
  87. /package/{content-engine/scripts → scripts/engine}/export-contracts.mjs +0 -0
  88. /package/{content-engine → scripts/engine}/fixtures/golden-run-en/draft.md +0 -0
  89. /package/{content-engine → scripts/engine}/fixtures/golden-run-en/idea.md +0 -0
  90. /package/{content-engine → scripts/engine}/fixtures/golden-run-en/pack-tiktok.json +0 -0
  91. /package/{content-engine/scripts → scripts/engine}/validate-export.mjs +0 -0
  92. /package/{content-engine/scripts → scripts/engine}/validate-pack.mjs +0 -0
  93. /package/{content-engine/scripts → scripts/engine}/validate-run.mjs +0 -0
@@ -1,1467 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Scan Content Engine indexes → workbench/index.html (read-only)."""
3
-
4
- from __future__ import annotations
5
-
6
- import json
7
- import re
8
- from datetime import datetime, timezone
9
- from pathlib import Path
10
-
11
- ROOT = Path(__file__).resolve().parent.parent
12
- OUT = Path(__file__).resolve().parent / "index.html"
13
- TPL = Path(__file__).resolve().parent / "template.html"
14
-
15
- PILLAR_HEAD = re.compile(r"^###\s+(P\d+)\s+[—–-]\s+(.+)$", re.M)
16
- # Known Chinese titles keyed by English heading from pillars.md (profile-agnostic fallbacks by P-id still OK for current vault).
17
- PILLAR_ZH_BY_EN = {
18
- "Agency content problems": "机构内容生产问题",
19
- "AI content systems": "AI 内容系统",
20
- "Building EasySociable": "建造 EasySociable",
21
- }
22
- PILLAR_ZH_BY_ID = {
23
- "P1": "机构内容生产问题",
24
- "P2": "AI 内容系统",
25
- "P3": "建造 EasySociable",
26
- }
27
- CAPTURE_RE = re.compile(r"C-\d{8}-\d{2}")
28
- SWIPE_RE = re.compile(r"\bS\d+\b")
29
- ATOM_RE = re.compile(r"\bA-[a-z0-9-]+\b")
30
- CLAIM_RE = re.compile(r"\bC-(?!\d{8})[a-z0-9-]+\b")
31
- RUN_RE = re.compile(r"RUN-[A-Za-z0-9-]+")
32
- PRODUCT_RE = re.compile(r"\bP-[A-Za-z0-9-]+\b")
33
- RECOMMENDATION_RE = re.compile(r"\bR-[A-Za-z0-9-]+\b")
34
- WIKI = re.compile(r"\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]")
35
-
36
-
37
- def parse_pillars_file(path: Path) -> dict:
38
- if not path.exists():
39
- return {}
40
- out = {}
41
- for m in PILLAR_HEAD.finditer(path.read_text(encoding="utf-8")):
42
- pid, title = m.group(1), m.group(2)
43
- title = re.sub(r"\s*\([^)]*\)\s*$", "", title).strip()
44
- out[pid] = {
45
- "en": title,
46
- "zh": PILLAR_ZH_BY_EN.get(title) or PILLAR_ZH_BY_ID.get(pid, title),
47
- }
48
- return out
49
-
50
-
51
- def parse_registry() -> tuple[list[dict], list[dict], dict]:
52
- """profiles, accounts, pillars_by_profile."""
53
- profiles = []
54
- for row in table_with(read("profiles/_index.md"), "id", "path", "voice", "one_liner", "accounts"):
55
- if not row["id"] or row["id"].startswith("---"):
56
- continue
57
- path = row["path"].strip().strip("`") or f"profiles/{row['id']}/"
58
- if not path.endswith("/"):
59
- path = f"{path}/"
60
- voice = row["voice"].strip().strip("`")
61
- voice = re.split(r"\s*\(", voice, 1)[0].strip().strip("`")
62
- profiles.append(
63
- {
64
- "id": row["id"],
65
- "path": path,
66
- "voice": voice,
67
- "one_liner": row["one_liner"],
68
- "accounts": [a.strip() for a in row["accounts"].split(",") if a.strip()],
69
- }
70
- )
71
-
72
- accounts = []
73
- for row in table_with(
74
- read("accounts/_index.md"),
75
- "handle",
76
- "profile_id",
77
- "voice",
78
- "platforms",
79
- "default_platform",
80
- "overrides",
81
- "status",
82
- ):
83
- if not row["handle"] or row["handle"].startswith("---"):
84
- continue
85
- accounts.append(
86
- {
87
- "handle": row["handle"],
88
- "profile_id": row["profile_id"],
89
- "voice": row["voice"],
90
- "platforms": row["platforms"],
91
- "default_platform": row["default_platform"],
92
- "overrides": row["overrides"],
93
- "status": row["status"],
94
- }
95
- )
96
-
97
- pillars_by_profile = {}
98
- for p in profiles:
99
- pillars_by_profile[p["id"]] = parse_pillars_file(ROOT / p["path"] / "pillars.md")
100
- return profiles, accounts, pillars_by_profile
101
-
102
-
103
- def read(rel: str) -> str:
104
- return (ROOT / rel).read_text(encoding="utf-8")
105
-
106
-
107
- def extract_tables(md: str) -> list[dict]:
108
- blocks: list[list[str]] = []
109
- cur: list[str] = []
110
- for line in md.splitlines():
111
- stripped = line.strip()
112
- if stripped.startswith("|"):
113
- cur.append(line)
114
- elif not stripped:
115
- # Blank lines inside a GFM table must not split the block,
116
- # or later rows are parsed as a new headerless table.
117
- continue
118
- elif cur:
119
- blocks.append(cur)
120
- cur = []
121
- if cur:
122
- blocks.append(cur)
123
- tables = []
124
- for block in blocks:
125
- rows = []
126
- for line in block:
127
- raw = line.replace("\\|", "\x1e")
128
- cells = [c.replace("\x1e", "|").strip() for c in raw.strip().strip("|").split("|")]
129
- rows.append(cells)
130
- if len(rows) < 2:
131
- continue
132
- headers = [h.strip() for h in rows[0]]
133
- if all(set(h) <= set("-: ") for h in rows[1]):
134
- body = rows[2:]
135
- else:
136
- body = rows[1:]
137
- recs = []
138
- for row in body:
139
- if not any(row):
140
- continue
141
- rec = {}
142
- for i, h in enumerate(headers):
143
- rec[h] = row[i].strip() if i < len(row) else ""
144
- recs.append(rec)
145
- tables.append({"headers": headers, "rows": recs})
146
- return tables
147
-
148
-
149
- def table_with(md: str, *need: str) -> list[dict]:
150
- need_l = [n.lower() for n in need]
151
- for t in extract_tables(md):
152
- hs = [h.lower() for h in t["headers"]]
153
- if all(n in hs for n in need_l):
154
- key = {h.lower(): h for h in t["headers"]}
155
- out = []
156
- for row in t["rows"]:
157
- out.append({n: row.get(key[n], "") for n in need_l})
158
- return out
159
- return []
160
-
161
-
162
- def hit_int(row: dict, key: str) -> int:
163
- try:
164
- return int(str(row.get(key, "") or "0").strip() or 0)
165
- except ValueError:
166
- return 0
167
-
168
-
169
- HTTP_RE = re.compile(r"https?://[^\s\]\)>'\"|]+")
170
-
171
-
172
- def wiki_path(cell: str) -> str:
173
- m = WIKI.search(cell or "")
174
- if not m:
175
- raw = (cell or "").strip().strip("`")
176
- return raw if raw.endswith(".md") and "://" not in raw else ""
177
- p = m.group(1).strip()
178
- if p.endswith(".md"):
179
- return p
180
- return p
181
-
182
-
183
- def run_folder_from_cell(cell: str) -> str:
184
- p = wiki_path(cell)
185
- parts = [x for x in p.replace("\\", "/").split("/") if x]
186
- if len(parts) >= 2 and parts[0] == "runs":
187
- return parts[1]
188
- return ""
189
-
190
-
191
- def frontmatter(text: str) -> dict[str, str]:
192
- if not text.startswith("---"):
193
- return {}
194
- end = text.find("\n---", 3)
195
- if end < 0:
196
- return {}
197
- meta = {}
198
- for line in text[4:end].splitlines():
199
- if ":" not in line:
200
- continue
201
- k, v = line.split(":", 1)
202
- meta[k.strip()] = v.strip().strip("\"'")
203
- return meta
204
-
205
-
206
- def parse_library(cell: str) -> list[tuple[str, str]]:
207
- if not cell or cell.strip() in ("none", "—", "-"):
208
- return []
209
- out = []
210
- for part in cell.split(";"):
211
- part = part.strip()
212
- if ":" not in part:
213
- continue
214
- kind, rest = part.split(":", 1)
215
- kind = kind.strip().lower()
216
- kind = {"atoms": "atom", "claims": "claim", "swipes": "swipe"}.get(kind, kind)
217
- for tok in rest.split(","):
218
- tok = tok.strip()
219
- if tok:
220
- out.append((kind, tok))
221
- return out
222
-
223
-
224
- def nid(kind: str, ident: str) -> str:
225
- return f"{kind}:{ident}"
226
-
227
-
228
- def vault_file(path: str) -> str:
229
- path = path.replace("\\", "/").lstrip("/")
230
- if path and not path.endswith(".md") and "." not in Path(path).name:
231
- # folder wiki → idea.md if run folder
232
- pass
233
- return f"Content Engine/{path}"
234
-
235
-
236
- def obsidian_uri(engine_rel: str) -> str:
237
- file_enc = vault_file(engine_rel).replace(" ", "%20")
238
- return f"obsidian://open?vault=Obsidian%20Vault&file={file_enc}"
239
-
240
-
241
- def node(
242
- kind: str,
243
- ident: str,
244
- label: str,
245
- path: str,
246
- extra: dict,
247
- ) -> dict:
248
- d = {
249
- "id": nid(kind, ident),
250
- "kind": kind,
251
- "ident": ident,
252
- "label": label,
253
- "path": path,
254
- "obsidian": obsidian_uri(path) if path else "",
255
- **extra,
256
- }
257
- return d
258
-
259
-
260
- def ids_in(text: str) -> dict[str, list[str]]:
261
- return {
262
- "capture": sorted(set(CAPTURE_RE.findall(text or ""))),
263
- "swipe": sorted(set(SWIPE_RE.findall(text or ""))),
264
- "atom": sorted(set(ATOM_RE.findall(text or ""))),
265
- "claim": sorted(set(CLAIM_RE.findall(text or ""))),
266
- "run": sorted(set(RUN_RE.findall(text or ""))),
267
- }
268
-
269
-
270
- def field_ids(val: str, kind: str) -> list[str]:
271
- v = (val or "").strip()
272
- if not v or v.lower() == "none":
273
- return []
274
- return ids_in(v).get(kind, [])
275
-
276
-
277
- STAGE_NAMES = (
278
- "idea",
279
- "brief",
280
- "packet",
281
- "draft",
282
- "editor",
283
- "rubric",
284
- "pack",
285
- "feedback",
286
- )
287
-
288
-
289
- def run_stage_files(folder: Path, folder_name: str) -> dict[str, str]:
290
- """Stage name → vault-relative .md path (only files that exist)."""
291
- base = f"runs/{folder_name}"
292
- out: dict[str, str] = {}
293
- for name in STAGE_NAMES:
294
- p = folder / f"{name}.md"
295
- if p.exists():
296
- out[name] = f"{base}/{name}.md"
297
- if "pack" not in out:
298
- packs = sorted(folder.glob("pack-*.md"))
299
- if packs:
300
- out["pack"] = f"{base}/{packs[0].name}"
301
- return out
302
-
303
-
304
- def parse_feedback_metrics(text: str) -> dict:
305
- out = {"has_numbers": False, "result": ""}
306
- fm = frontmatter(text)
307
- if fm.get("result"):
308
- out["result"] = fm["result"]
309
- if re.search(r"(views|likes|replies|bookmarks)\s*:", text, re.I):
310
- out["has_numbers"] = True
311
- return out
312
-
313
-
314
- ENGAGEMENT_SNAP_RE = re.compile(
315
- r"(?:engagement\s+snapshot|snapshot\s+engagement)[^\n:]*:\s*(.+?)(?:\n|$)",
316
- re.I,
317
- )
318
- ENGAGEMENT_SECTION_RE = re.compile(
319
- r"^##\s+(?:Engagement\s+snapshot|Snapshot\s+metrics(?:\s*\([^)]*\))?)\s*\n+(.*?)(?=^##\s|\Z)",
320
- re.I | re.M | re.S,
321
- )
322
- ENGAGEMENT_KIND = r"likes?|replies|reply|bookmarks?|views?"
323
- ENGAGEMENT_NUM = r"~?\d[\d,]*(?:\.\d+)?[kKmMbB]?"
324
- # kind-first: likes 113 / likes: 3
325
- ENGAGEMENT_KIND_NUM_RE = re.compile(
326
- rf"\b({ENGAGEMENT_KIND})\s*[:=]?\s*({ENGAGEMENT_NUM})\b",
327
- re.I,
328
- )
329
- # num-first: 1086 likes / ~92.9k views
330
- ENGAGEMENT_NUM_KIND_RE = re.compile(
331
- rf"({ENGAGEMENT_NUM})\s+({ENGAGEMENT_KIND})\b",
332
- re.I,
333
- )
334
- ENGAGEMENT_AT_RE = re.compile(r"@\s*(\d{4}-\d{2}-\d{2})")
335
- ENGAGEMENT_FETCH_AT_RE = re.compile(
336
- r"(?:fetch|at\s+capture|snapshot_at)\s*[:=]?\s*(\d{4}-\d{2}-\d{2})",
337
- re.I,
338
- )
339
-
340
-
341
- def _empty_engagement() -> dict[str, str]:
342
- return {
343
- "src_likes": "",
344
- "src_replies": "",
345
- "src_bookmarks": "",
346
- "src_views": "",
347
- "engagement_at": "",
348
- }
349
-
350
-
351
- def _normalize_metric_kind(kind: str) -> str:
352
- k = kind.lower()
353
- if k.startswith("like"):
354
- return "likes"
355
- if k.startswith("repl"):
356
- return "replies"
357
- if k.startswith("bookmark"):
358
- return "bookmarks"
359
- if k.startswith("view"):
360
- return "views"
361
- return k
362
-
363
-
364
- def _parse_compact_count(raw: str) -> str:
365
- """'92.9k' → '92900'; '1.1M' → '1100000'; '9,574' → '9574'."""
366
- s = raw.strip().lstrip("~").replace(",", "")
367
- if not s:
368
- return ""
369
- m = re.fullmatch(r"(\d+(?:\.\d+)?)([kKmMbB])?", s)
370
- if not m:
371
- digits = re.sub(r"[^\d]", "", s)
372
- return digits
373
- n = float(m.group(1))
374
- suf = (m.group(2) or "").lower()
375
- if suf == "k":
376
- n *= 1_000
377
- elif suf == "m":
378
- n *= 1_000_000
379
- elif suf == "b":
380
- n *= 1_000_000_000
381
- return str(int(round(n)))
382
-
383
-
384
- def _fill_engagement_from_blob(out: dict[str, str], blob: str) -> None:
385
- if not blob:
386
- return
387
- # Prefer kind-first matches (list form), then num-first (capture prose).
388
- for kind, num in ENGAGEMENT_KIND_NUM_RE.findall(blob):
389
- key = f"src_{_normalize_metric_kind(kind)}"
390
- if key in out and not out[key]:
391
- out[key] = _parse_compact_count(num)
392
- for num, kind in ENGAGEMENT_NUM_KIND_RE.findall(blob):
393
- key = f"src_{_normalize_metric_kind(kind)}"
394
- if key in out and not out[key]:
395
- out[key] = _parse_compact_count(num)
396
- if not out["engagement_at"]:
397
- at = ENGAGEMENT_AT_RE.search(blob) or ENGAGEMENT_FETCH_AT_RE.search(blob)
398
- if at:
399
- out["engagement_at"] = at.group(1)
400
-
401
-
402
- def parse_engagement_snapshot(text: str) -> dict[str, str]:
403
- """Parse engagement into src_* fields from Capture / Raw notes.
404
-
405
- Supports:
406
- - Compact: `engagement snapshot: likes 113 / replies 18 / ... @ 2026-08-30`
407
- - Capture prose: `Engagement snapshot (fetch DATE): ~92.9k views · 1086 likes · ...`
408
- - Raw list under `## Engagement snapshot` with `- likes: 3`
409
- Parenthetical secondary demos after the primary snapshot are ignored.
410
- """
411
- out = _empty_engagement()
412
- if not text:
413
- return out
414
- m = ENGAGEMENT_SNAP_RE.search(text)
415
- if m:
416
- blob = m.group(1).split("(", 1)[0]
417
- # Date often sits in the label: Engagement snapshot (fetch 2026-08-25)
418
- label = text[m.start() : m.start(1)]
419
- _fill_engagement_from_blob(out, blob)
420
- if not out["engagement_at"]:
421
- at = ENGAGEMENT_FETCH_AT_RE.search(label) or ENGAGEMENT_AT_RE.search(label)
422
- if at:
423
- out["engagement_at"] = at.group(1)
424
- return out
425
- sec = ENGAGEMENT_SECTION_RE.search(text)
426
- if sec:
427
- _fill_engagement_from_blob(out, sec.group(1))
428
- return out
429
-
430
-
431
- def merge_engagement(*parts: dict[str, str]) -> dict[str, str]:
432
- out = _empty_engagement()
433
- for part in parts:
434
- for k, v in part.items():
435
- if k in out and v and not out[k]:
436
- out[k] = v
437
- return out
438
-
439
- def build_graph() -> dict:
440
- nodes: dict[str, dict] = {}
441
- edges: list[dict] = []
442
- profiles, accounts, pillars_by_profile = parse_registry()
443
-
444
- def add_node(n: dict) -> None:
445
- nodes[n["id"]] = n
446
-
447
- def edge(a: str, b: str, rel: str) -> None:
448
- if a in nodes and b in nodes:
449
- edges.append({"from": a, "to": b, "rel": rel})
450
-
451
- for p in profiles:
452
- base = p["path"].rstrip("/") + "/"
453
- files = {}
454
- for name in ("README", "voice", "audience", "pillars", "lanes", "keywords", "handles"):
455
- rel = f"{base}{name}.md"
456
- if (ROOT / rel).exists():
457
- files[name.lower()] = rel
458
- readme = files.get("readme") or files.get("pillars") or f"{base}pillars.md"
459
- add_node(
460
- node(
461
- "profile",
462
- p["id"],
463
- p["one_liner"] or p["id"],
464
- readme,
465
- {
466
- "one_liner": p["one_liner"],
467
- "voice": p["voice"],
468
- "accounts": p["accounts"],
469
- "pillar_ids": list(pillars_by_profile.get(p["id"], {}).keys()),
470
- "files": files,
471
- "dir": base,
472
- },
473
- )
474
- )
475
- for a in accounts:
476
- add_node(
477
- node(
478
- "account",
479
- a["handle"],
480
- a["handle"],
481
- "accounts/_index.md",
482
- {
483
- "profile_id": a["profile_id"],
484
- "platforms": a["platforms"],
485
- "default_platform": a["default_platform"],
486
- "status": a["status"],
487
- "voice": a["voice"],
488
- "overrides": a["overrides"],
489
- },
490
- )
491
- )
492
- if a["profile_id"]:
493
- edges.append(
494
- {"from": nid("account", a["handle"]), "to": nid("profile", a["profile_id"]), "rel": "uses_profile"}
495
- )
496
-
497
- for row in table_with(read("inbox/_index.md"), "id", "date", "type", "one_liner", "platforms", "profile", "entry", "raw", "library", "status"):
498
- ident = row["id"]
499
- if not ident.startswith("C-"):
500
- continue
501
- entry = wiki_path(row["entry"]) or f"inbox/entries/{ident}"
502
- if not entry.endswith(".md"):
503
- entry = f"{entry}.md"
504
- raw_path = wiki_path(row["raw"])
505
- add_node(
506
- node(
507
- "capture",
508
- ident,
509
- row["one_liner"],
510
- entry,
511
- {
512
- "date": row["date"],
513
- "type": row["type"],
514
- "platforms": row["platforms"],
515
- "profile": row["profile"],
516
- "status": row["status"],
517
- "library_cell": row["library"],
518
- "raw_path": raw_path,
519
- "has_raw": bool(raw_path),
520
- "ingested": row["status"] == "ingested",
521
- },
522
- )
523
- )
524
- for kind, lib_id in parse_library(row["library"]):
525
- edges.append({"from": nid("capture", ident), "to": nid(kind, lib_id), "rel": "ingested_as"})
526
-
527
- needs_md = read("needs/_index.md")
528
- needs_rows = table_with(
529
- needs_md,
530
- "id",
531
- "date",
532
- "profile",
533
- "source_type",
534
- "quote",
535
- "speaker",
536
- "frequency",
537
- "status",
538
- "topic_ids",
539
- "file",
540
- )
541
- for row in needs_rows:
542
- ident = row["id"]
543
- if not ident.startswith("N-"):
544
- continue
545
- fpath = wiki_path(row["file"]) or f"needs/entries/{ident}.md"
546
- if not fpath.endswith(".md"):
547
- fpath = f"{fpath}.md"
548
- topic_ids = (row.get("topic_ids") or "").strip()
549
- quote = (row.get("quote") or "").strip()
550
- add_node(
551
- node(
552
- "need",
553
- ident,
554
- quote or f"Need {ident}",
555
- fpath,
556
- {
557
- "date": row["date"],
558
- "profile": row["profile"],
559
- "source_type": row["source_type"],
560
- "quote": quote,
561
- "speaker": row.get("speaker", "") or "",
562
- "frequency": row.get("frequency", "") or "1",
563
- "status": row["status"],
564
- "topic_ids": topic_ids,
565
- },
566
- )
567
- )
568
- # Topic edges are wired after topic nodes exist (see post-pass below).
569
-
570
- hits_path = ROOT / "hits" / "_index.md"
571
- if hits_path.exists():
572
- hits_rows = table_with(
573
- hits_path.read_text(encoding="utf-8"),
574
- "id",
575
- "date",
576
- "profile",
577
- "platform",
578
- "keyword_id",
579
- "source",
580
- "hit_kind",
581
- "signal",
582
- "status",
583
- "excerpt",
584
- "src_likes",
585
- "src_replies",
586
- "src_bookmarks",
587
- "src_views",
588
- "engagement_at",
589
- "file",
590
- )
591
- if not hits_rows:
592
- hits_rows = table_with(
593
- hits_path.read_text(encoding="utf-8"),
594
- "id",
595
- "date",
596
- "profile",
597
- "platform",
598
- "source",
599
- "hit_kind",
600
- "signal",
601
- "status",
602
- "excerpt",
603
- "file",
604
- )
605
- for row in hits_rows:
606
- ident = row["id"]
607
- if not ident.startswith("H-"):
608
- continue
609
- fpath = wiki_path(row["file"]) or f"hits/entries/{ident}.md"
610
- if not fpath.endswith(".md"):
611
- fpath = f"{fpath}.md"
612
- excerpt = (row.get("excerpt") or "").strip()
613
- keyword_id = (row.get("keyword_id") or "").strip()
614
- if not keyword_id:
615
- m = re.search(r"\bK-\d{2}\b", row.get("source", "") or "")
616
- if m:
617
- keyword_id = m.group(0)
618
- eng = {
619
- "src_likes": row.get("src_likes", "") or "",
620
- "src_replies": row.get("src_replies", "") or "",
621
- "src_bookmarks": row.get("src_bookmarks", "") or "",
622
- "src_views": row.get("src_views", "") or "",
623
- "engagement_at": row.get("engagement_at", "") or "",
624
- }
625
- source_url = ""
626
- if fpath and (ROOT / fpath).exists():
627
- body = read(fpath)
628
- eng = merge_engagement(eng, parse_engagement_snapshot(body))
629
- fm = re.match(r"^---\n(.*?)\n---", body, re.S)
630
- if fm:
631
- murl = re.search(r"^source_url:\s*(\S+)", fm.group(1), re.M)
632
- if murl:
633
- source_url = murl.group(1).strip()
634
- if not keyword_id:
635
- mk = re.search(r"^keyword_id:\s*(\S+)", fm.group(1), re.M)
636
- if mk:
637
- keyword_id = mk.group(1).strip()
638
- add_node(
639
- node(
640
- "hit",
641
- ident,
642
- excerpt or f"Hit {ident}",
643
- fpath,
644
- {
645
- "date": row["date"],
646
- "profile": row["profile"],
647
- "platform": row.get("platform", "") or "",
648
- "keyword_id": keyword_id,
649
- "source": row.get("source", "") or keyword_id,
650
- "hit_kind": row.get("hit_kind", "") or "",
651
- "signal": row.get("signal", "") or "",
652
- "status": row["status"],
653
- "excerpt": excerpt,
654
- "source_url": source_url,
655
- "src_likes": eng.get("src_likes", "") or "",
656
- "src_replies": eng.get("src_replies", "") or "",
657
- "src_bookmarks": eng.get("src_bookmarks", "") or "",
658
- "src_views": eng.get("src_views", "") or "",
659
- "engagement_at": eng.get("engagement_at", "") or "",
660
- "src_heat_note": "来源帖热度,不是你的帖",
661
- },
662
- )
663
- )
664
-
665
- # Keyword recipes (profile listening config) + hit counts for Hits rail
666
- keywords: list[dict] = []
667
- for p in profiles:
668
- kpath = ROOT / p["path"].rstrip("/") / "keywords.md"
669
- if not kpath.exists():
670
- continue
671
- krows = table_with(
672
- kpath.read_text(encoding="utf-8"),
673
- "id",
674
- "track",
675
- "query",
676
- "platform",
677
- "intent",
678
- "layer",
679
- "pillar",
680
- "why",
681
- "weight",
682
- "status",
683
- "seed_from",
684
- "pair_id",
685
- "volume",
686
- "kd",
687
- "serp_intent",
688
- "volume_source",
689
- "volume_at",
690
- "ask_engines",
691
- "last_scanned",
692
- )
693
- if not krows:
694
- krows = table_with(
695
- kpath.read_text(encoding="utf-8"),
696
- "id",
697
- "query",
698
- "platform",
699
- "intent",
700
- "layer",
701
- "pillar",
702
- "why",
703
- "weight",
704
- "status",
705
- "last_scanned",
706
- )
707
- for row in krows:
708
- kid = (row.get("id") or "").strip()
709
- if not kid.startswith("K-"):
710
- continue
711
- track = (row.get("track") or "").strip() or "listen"
712
- keywords.append(
713
- {
714
- "id": kid,
715
- "profile": p["id"],
716
- "track": track,
717
- "query": row.get("query", "") or "",
718
- "platform": row.get("platform", "") or "",
719
- "intent": row.get("intent", "") or "",
720
- "layer": row.get("layer", "") or "",
721
- "pillar": row.get("pillar", "") or "",
722
- "why": row.get("why", "") or "",
723
- "weight": row.get("weight", "") or "",
724
- "status": row.get("status", "") or "",
725
- "seed_from": row.get("seed_from", "") or "",
726
- "pair_id": row.get("pair_id", "") or "",
727
- "volume": row.get("volume", "") or "",
728
- "kd": row.get("kd", "") or "",
729
- "serp_intent": row.get("serp_intent", "") or "",
730
- "volume_source": row.get("volume_source", "") or "",
731
- "volume_at": row.get("volume_at", "") or "",
732
- "ask_engines": row.get("ask_engines", "") or "",
733
- "last_scanned": row.get("last_scanned", "") or "",
734
- "path": f"{p['path'].rstrip('/')}/keywords.md",
735
- }
736
- )
737
-
738
- products_path = ROOT / "products" / "_index.md"
739
- if products_path.exists():
740
- product_rows = table_with(
741
- products_path.read_text(encoding="utf-8"),
742
- "id",
743
- "name",
744
- "status",
745
- "audience",
746
- "price",
747
- "effective_from",
748
- "file",
749
- )
750
- for row in product_rows:
751
- ident = row["id"]
752
- if not ident.startswith("P-"):
753
- continue
754
- fpath = wiki_path(row["file"]) or f"products/entries/{ident}.md"
755
- if not fpath.endswith(".md"):
756
- fpath = f"{fpath}.md"
757
- add_node(
758
- node(
759
- "product",
760
- ident,
761
- row["name"] or ident,
762
- fpath,
763
- {
764
- "status": row["status"],
765
- "audience": row["audience"],
766
- "price": row["price"],
767
- "effective_from": row["effective_from"],
768
- },
769
- )
770
- )
771
-
772
- recommendations_path = ROOT / "recommendations" / "_index.md"
773
- if recommendations_path.exists():
774
- recommendation_rows = table_with(
775
- recommendations_path.read_text(encoding="utf-8"),
776
- "id",
777
- "name",
778
- "status",
779
- "category",
780
- "profiles",
781
- "pillars",
782
- "last_researched",
783
- "relationship",
784
- "file",
785
- )
786
- for row in recommendation_rows:
787
- ident = row["id"]
788
- if not ident.startswith("R-"):
789
- continue
790
- fpath = wiki_path(row["file"]) or f"recommendations/{ident}.md"
791
- if not fpath.endswith(".md"):
792
- fpath = f"{fpath}.md"
793
- add_node(
794
- node(
795
- "recommendation",
796
- ident,
797
- row["name"] or ident,
798
- fpath,
799
- {
800
- "status": row["status"],
801
- "category": row["category"],
802
- "profiles": row["profiles"],
803
- "pillars": row["pillars"],
804
- "last_researched": row["last_researched"],
805
- "relationship": row["relationship"],
806
- },
807
- )
808
- )
809
-
810
- for row in table_with(read("raw/_index.md"), "id", "date", "capture_id", "file", "type", "source_url", "one_liner"):
811
- ident = row["id"]
812
- fpath = wiki_path(row["file"])
813
- if fpath and not fpath.endswith(".md"):
814
- fpath = f"{fpath}.md"
815
- add_node(
816
- node(
817
- "raw",
818
- ident,
819
- row["one_liner"],
820
- fpath,
821
- {
822
- "date": row["date"],
823
- "type": row["type"],
824
- "source_url": row["source_url"],
825
- "capture_id": row["capture_id"],
826
- },
827
- )
828
- )
829
- if row["capture_id"]:
830
- edge(nid("capture", row["capture_id"]), nid("raw", ident), "has_raw")
831
-
832
- topic_md = read("topics/_index.md")
833
- # Current: opp + aud (5C opportunity). Legacy: craft + fit, then single total.
834
- topic_rows = table_with(
835
- topic_md,
836
- "id",
837
- "date",
838
- "opp",
839
- "aud",
840
- "status",
841
- "purpose",
842
- "scout",
843
- "platform",
844
- "pillar",
845
- "lane",
846
- "generation_mode",
847
- "one_liner",
848
- "item",
849
- "source_type",
850
- "src_url",
851
- "src_handle",
852
- "src_likes",
853
- "src_replies",
854
- "src_bookmarks",
855
- "engagement_at",
856
- "capture_id",
857
- "need_ids",
858
- "product_ids",
859
- "product_fit",
860
- "recommendation_ids",
861
- "run",
862
- )
863
- if topic_rows:
864
- topic_rows = [
865
- {
866
- **row,
867
- "craft": row.get("opp", "") or "",
868
- "fit": row.get("aud", "") or "",
869
- }
870
- for row in topic_rows
871
- ]
872
- if not topic_rows:
873
- topic_rows = table_with(
874
- topic_md,
875
- "id",
876
- "date",
877
- "craft",
878
- "fit",
879
- "status",
880
- "purpose",
881
- "scout",
882
- "platform",
883
- "pillar",
884
- "one_liner",
885
- "item",
886
- "source_type",
887
- "src_url",
888
- "src_handle",
889
- "src_likes",
890
- "src_replies",
891
- "src_bookmarks",
892
- "engagement_at",
893
- "capture_id",
894
- "run",
895
- )
896
- if not topic_rows:
897
- topic_rows = [
898
- {
899
- **row,
900
- "craft": row["total"],
901
- "fit": "",
902
- "purpose": "",
903
- "scout": "",
904
- }
905
- for row in table_with(
906
- topic_md,
907
- "id",
908
- "date",
909
- "total",
910
- "status",
911
- "platform",
912
- "pillar",
913
- "one_liner",
914
- "item",
915
- "source_type",
916
- "src_url",
917
- "src_handle",
918
- "src_likes",
919
- "src_replies",
920
- "src_bookmarks",
921
- "engagement_at",
922
- "capture_id",
923
- "run",
924
- )
925
- ]
926
-
927
- for row in topic_rows:
928
- ident = row["id"]
929
- if not ident.startswith("T-"):
930
- continue
931
- item = wiki_path(row["item"])
932
- if item and not item.endswith(".md"):
933
- item = f"{item}.md"
934
- usage = "killed" if row["status"] == "killed" else ("promoted" if row["run"].strip() else "unused")
935
- craft = row.get("craft", "") or ""
936
- fit = row.get("fit", "") or ""
937
- # Sort key: opp (aliased as craft) / legacy total
938
- total = craft or row.get("total", "") or ""
939
- profile = ""
940
- if item and (ROOT / item).exists():
941
- fm = re.match(r"^---\n(.*?)\n---", read(item), re.S)
942
- if fm:
943
- mprof = re.search(r"^profile:\s*(\S+)", fm.group(1), re.M)
944
- if mprof:
945
- profile = mprof.group(1).strip()
946
- add_node(
947
- node(
948
- "topic",
949
- ident,
950
- row["one_liner"],
951
- item,
952
- {
953
- "date": row["date"],
954
- "total": total,
955
- "opp": craft,
956
- "aud": fit,
957
- "craft": craft,
958
- "fit": fit,
959
- "purpose": row.get("purpose", "") or "",
960
- "scout": row.get("scout", "") or "",
961
- "status": row["status"],
962
- "platform": row["platform"],
963
- "pillar": row["pillar"],
964
- "lane": row.get("lane", "") or "",
965
- "generation_mode": row.get("generation_mode", "") or "",
966
- "profile": profile,
967
- "source_type": row["source_type"],
968
- "src_url": row["src_url"],
969
- "src_handle": row["src_handle"],
970
- "src_likes": row["src_likes"],
971
- "src_replies": row["src_replies"],
972
- "src_bookmarks": row["src_bookmarks"],
973
- "engagement_at": row["engagement_at"],
974
- "capture_id": row["capture_id"],
975
- "need_ids": row.get("need_ids", "") or "",
976
- "product_ids": row.get("product_ids", "") or "",
977
- "product_fit": row.get("product_fit", "") or "",
978
- "recommendation_ids": row.get("recommendation_ids", "") or "",
979
- "run_id": row["run"].strip(),
980
- "usage": usage,
981
- "src_heat_note": "来源帖热度,不是你的帖",
982
- },
983
- )
984
- )
985
- if row["capture_id"]:
986
- edge(nid("topic", ident), nid("capture", row["capture_id"]), "from_capture")
987
- if row["run"].strip().startswith("RUN-"):
988
- edge(nid("topic", ident), nid("run", row["run"].strip()), "opened_run")
989
- # Index cells are enough for edges; item frontmatter may repeat the same ids.
990
- for need_id in re.findall(r"\bN-\d{8}-\d{2}\b", row.get("need_ids", "") or ""):
991
- edge(nid("topic", ident), nid("need", need_id), "from_need")
992
- for product_id in PRODUCT_RE.findall(row.get("product_ids", "") or ""):
993
- edge(nid("topic", ident), nid("product", product_id), "uses_product")
994
- for recommendation_id in RECOMMENDATION_RE.findall(row.get("recommendation_ids", "") or ""):
995
- edge(nid("topic", ident), nid("recommendation", recommendation_id), "reviews")
996
- if item and (ROOT / item).exists():
997
- body = read(item)
998
- fm = re.match(r"^---\n(.*?)\n---", body, re.S)
999
- if fm:
1000
- mneeds = re.search(r"^need_ids:\s*(.*)$", fm.group(1), re.M)
1001
- if mneeds:
1002
- for need_id in re.findall(r"\bN-\d{8}-\d{2}\b", mneeds.group(1)):
1003
- edge(nid("topic", ident), nid("need", need_id), "from_need")
1004
- mproducts = re.search(r"^product_ids:\s*(.*)$", fm.group(1), re.M)
1005
- if mproducts:
1006
- for product_id in PRODUCT_RE.findall(mproducts.group(1)):
1007
- edge(nid("topic", ident), nid("product", product_id), "uses_product")
1008
- mrecommendations = re.search(r"^recommendation_ids:\s*(.*)$", fm.group(1), re.M)
1009
- if mrecommendations:
1010
- for recommendation_id in RECOMMENDATION_RE.findall(mrecommendations.group(1)):
1011
- edge(nid("topic", ident), nid("recommendation", recommendation_id), "reviews")
1012
- chunk = body
1013
- m = re.search(r"^## Suggested production.*?(?=^## |\Z)|^## Trace.*?(?=^## |\Z)", body, re.M | re.S)
1014
- if m:
1015
- chunk = m.group(0)
1016
- for line in chunk.splitlines():
1017
- if ":" not in line:
1018
- continue
1019
- key, rest = line.split(":", 1)
1020
- key, rest = key.strip().lower(), rest.strip()
1021
- if rest.lower().startswith("none"):
1022
- continue
1023
- if "swipe" in key:
1024
- for sid in ids_in(rest)["swipe"]:
1025
- edges.append({"from": nid("topic", ident), "to": nid("swipe", sid), "rel": "suggested"})
1026
- elif "atom" in key:
1027
- for aid in ids_in(rest)["atom"]:
1028
- edges.append({"from": nid("topic", ident), "to": nid("atom", aid), "rel": "suggested"})
1029
- elif "claim" in key:
1030
- for cid in ids_in(rest)["claim"]:
1031
- edges.append({"from": nid("topic", ident), "to": nid("claim", cid), "rel": "suggested"})
1032
-
1033
- for n in nodes.values():
1034
- if n["kind"] != "need":
1035
- continue
1036
- for tid in re.findall(r"\bT-\d{8}-\d{2}\b", n.get("topic_ids") or ""):
1037
- edge(n["id"], nid("topic", tid), "evidence_for")
1038
-
1039
- for row in table_with(
1040
- read("swipe/_index.md"),
1041
- "id",
1042
- "file",
1043
- "platforms",
1044
- "profiles",
1045
- "form",
1046
- "beats_or_pages",
1047
- "status",
1048
- "n",
1049
- "win",
1050
- "loss",
1051
- "one_liner",
1052
- "source",
1053
- ):
1054
- ident = row["id"]
1055
- fpath = row["file"]
1056
- if fpath and not fpath.startswith("swipe/"):
1057
- fpath = f"swipe/{fpath}"
1058
- if fpath and not fpath.endswith(".md"):
1059
- fpath = f"{fpath}.md"
1060
- add_node(
1061
- node(
1062
- "swipe",
1063
- ident,
1064
- row["one_liner"],
1065
- fpath,
1066
- {
1067
- "platforms": row["platforms"],
1068
- "profiles": row["profiles"],
1069
- "form": row["form"],
1070
- "beats": row["beats_or_pages"],
1071
- "status": row["status"],
1072
- "n": hit_int(row, "n"),
1073
- "win": hit_int(row, "win"),
1074
- "loss": hit_int(row, "loss"),
1075
- "source_cell": row["source"],
1076
- "lib": True,
1077
- },
1078
- )
1079
- )
1080
- for cid in CAPTURE_RE.findall(row["source"]):
1081
- edge(nid("swipe", ident), nid("capture", cid), "sourced_from")
1082
-
1083
- for row in table_with(
1084
- read("wiki/atoms/_index.md"),
1085
- "id",
1086
- "file",
1087
- "type",
1088
- "platforms",
1089
- "profiles",
1090
- "status",
1091
- "n",
1092
- "win",
1093
- "loss",
1094
- "one_liner",
1095
- ):
1096
- ident = row["id"]
1097
- fpath = row["file"]
1098
- if fpath and not fpath.startswith("wiki/"):
1099
- fpath = f"wiki/atoms/{fpath}"
1100
- if fpath and not fpath.endswith(".md"):
1101
- fpath = f"{fpath}.md"
1102
- add_node(
1103
- node(
1104
- "atom",
1105
- ident,
1106
- row["one_liner"],
1107
- fpath,
1108
- {
1109
- "atom_type": row["type"],
1110
- "platforms": row["platforms"],
1111
- "profiles": row["profiles"],
1112
- "status": row["status"],
1113
- "n": hit_int(row, "n"),
1114
- "win": hit_int(row, "win"),
1115
- "loss": hit_int(row, "loss"),
1116
- "lib": True,
1117
- },
1118
- )
1119
- )
1120
- if (ROOT / fpath).exists():
1121
- for cid in CAPTURE_RE.findall(read(fpath)):
1122
- edge(nid("atom", ident), nid("capture", cid), "sourced_from")
1123
-
1124
- for row in table_with(
1125
- read("wiki/claims/_index.md"),
1126
- "id",
1127
- "file",
1128
- "type",
1129
- "platforms",
1130
- "profiles",
1131
- "status",
1132
- "n",
1133
- "win",
1134
- "loss",
1135
- "one_liner",
1136
- ):
1137
- ident = row["id"]
1138
- fpath = row["file"]
1139
- if fpath and not fpath.startswith("wiki/"):
1140
- fpath = f"wiki/claims/{fpath}"
1141
- if fpath and not fpath.endswith(".md"):
1142
- fpath = f"{fpath}.md"
1143
- evidence_runs = []
1144
- if (ROOT / fpath).exists():
1145
- body = read(fpath)
1146
- for cid in CAPTURE_RE.findall(body):
1147
- edge(nid("claim", ident), nid("capture", cid), "sourced_from")
1148
- in_ev = False
1149
- for line in body.splitlines():
1150
- if line.startswith("## Evidence"):
1151
- in_ev = True
1152
- continue
1153
- if in_ev and line.startswith("## "):
1154
- break
1155
- if in_ev:
1156
- evidence_runs += RUN_RE.findall(line)
1157
- add_node(
1158
- node(
1159
- "claim",
1160
- ident,
1161
- row["one_liner"],
1162
- fpath,
1163
- {
1164
- "claim_type": row["type"],
1165
- "platforms": row["platforms"],
1166
- "profiles": row["profiles"],
1167
- "status": row["status"],
1168
- "n": hit_int(row, "n"),
1169
- "win": hit_int(row, "win"),
1170
- "loss": hit_int(row, "loss"),
1171
- "evidence_runs": evidence_runs,
1172
- "has_evidence": bool(evidence_runs),
1173
- "lib": True,
1174
- },
1175
- )
1176
- )
1177
-
1178
- for row in table_with(
1179
- read("runs/_index.md"),
1180
- "run_id",
1181
- "path",
1182
- "date",
1183
- "status",
1184
- "account",
1185
- "profile",
1186
- "primary_platform",
1187
- "platforms",
1188
- "topic_id",
1189
- "pillar",
1190
- "one_liner",
1191
- "pack",
1192
- "export",
1193
- "notes",
1194
- ):
1195
- ident = row["run_id"]
1196
- if not ident.startswith("RUN-"):
1197
- continue
1198
- folder_wiki = wiki_path(row["path"])
1199
- folder = folder_wiki.replace("runs/", "").strip("/")
1200
- rdir = ROOT / "runs" / folder if folder else None
1201
- stage_files = run_stage_files(rdir, folder) if rdir and rdir.is_dir() and folder else {}
1202
- stages = {n: n in stage_files for n in STAGE_NAMES}
1203
- idea_path = stage_files.get("idea") or (f"runs/{folder}/idea.md" if folder else "")
1204
- selection = {"swipe": "none", "atoms": "none", "claim": "none"}
1205
- supersedes = ""
1206
- superseded_by = ""
1207
- capture_ids = []
1208
- if idea_path and (ROOT / idea_path).exists():
1209
- idea = read(idea_path)
1210
- fm = frontmatter(idea)
1211
- supersedes = fm.get("supersedes", "")
1212
- superseded_by = fm.get("superseded_by", "")
1213
- for line in idea.splitlines():
1214
- if "swipe_id" in line:
1215
- selection["swipe"] = line.split(":", 1)[-1].strip().lstrip("- ").replace("**swipe_id:**", "").strip()
1216
- if "**atoms:**" in line or line.strip().startswith("- **atoms:**"):
1217
- selection["atoms"] = line.split(":", 1)[-1].strip().lstrip("- ").replace("**atoms:**", "").strip()
1218
- if "hypothesis" in line and "swipe_why" not in line:
1219
- selection["claim"] = line.split(":", 1)[-1].strip().lstrip("- ").replace("**hypothesis:**", "").strip()
1220
- if "claim_id" in line:
1221
- selection["claim"] = line.split(":", 1)[-1].strip().lstrip("- ").replace("**claim_id:**", "").strip()
1222
- if "capture_ids" in line:
1223
- capture_ids = CAPTURE_RE.findall(line)
1224
- sm = re.search(r"\*\*swipe_id:\*\*\s*(\S+)", idea)
1225
- if sm:
1226
- selection["swipe"] = sm.group(1)
1227
- am = re.search(r"\*\*atoms:\*\*\s*(.+)", idea)
1228
- if am:
1229
- selection["atoms"] = am.group(1).strip()
1230
- cm = re.search(r"\*\*(?:claim_id|hypothesis):\*\*\s*(\S+)", idea)
1231
- if cm:
1232
- selection["claim"] = cm.group(1)
1233
- metrics = {}
1234
- ship_urls = []
1235
- fb = rdir / "feedback.md" if rdir else None
1236
- if fb and fb.exists():
1237
- fb_text = fb.read_text(encoding="utf-8")
1238
- metrics = parse_feedback_metrics(fb_text)
1239
- ship_urls = [u.rstrip(".,;") for u in HTTP_RE.findall(fb_text)]
1240
- add_node(
1241
- node(
1242
- "run",
1243
- ident,
1244
- row["one_liner"],
1245
- f"runs/{folder}" if folder else "",
1246
- {
1247
- "date": row["date"],
1248
- "status": row["status"],
1249
- "account": row["account"],
1250
- "profile": row["profile"],
1251
- "primary_platform": row["primary_platform"],
1252
- "platforms": row["platforms"],
1253
- "topic_id": row["topic_id"] if row["topic_id"] != "none" else "",
1254
- "pillar": row["pillar"],
1255
- "pack": row["pack"],
1256
- "export": row["export"],
1257
- "notes": row["notes"],
1258
- "folder": folder,
1259
- "stages": stages,
1260
- "stage_files": stage_files,
1261
- "selection": selection,
1262
- "supersedes": supersedes,
1263
- "superseded_by": superseded_by,
1264
- "metrics": metrics,
1265
- "ship_urls": ship_urls,
1266
- "open_file": idea_path,
1267
- "obsidian": obsidian_uri(idea_path) if idea_path else "",
1268
- },
1269
- )
1270
- )
1271
- if row["topic_id"] not in ("", "none"):
1272
- edge(nid("run", ident), nid("topic", row["topic_id"]), "from_topic")
1273
- for cid in capture_ids:
1274
- edge(nid("run", ident), nid("capture", cid), "from_capture")
1275
- for sid in field_ids(selection["swipe"], "swipe"):
1276
- edge(nid("run", ident), nid("swipe", sid), "uses")
1277
- for aid in field_ids(selection["atoms"], "atom"):
1278
- edge(nid("run", ident), nid("atom", aid), "uses")
1279
- for cid in field_ids(selection["claim"], "claim"):
1280
- edge(nid("run", ident), nid("claim", cid), "uses")
1281
- if supersedes.startswith("RUN-"):
1282
- edges.append({"from": nid("run", ident), "to": nid("run", supersedes), "rel": "supersedes"})
1283
-
1284
- folder_to_run = {
1285
- n["folder"]: n["ident"] for n in nodes.values() if n["kind"] == "run" and n.get("folder")
1286
- }
1287
-
1288
- for row in table_with(
1289
- read("published/_index.md"),
1290
- "date",
1291
- "platform",
1292
- "url",
1293
- "run",
1294
- "pillar",
1295
- "swipe",
1296
- "atoms",
1297
- "claim",
1298
- "result",
1299
- "notes",
1300
- ):
1301
- if not row["url"].startswith("http"):
1302
- continue
1303
- run_cell = row["run"]
1304
- run_folder = run_folder_from_cell(run_cell)
1305
- run_id = (RUN_RE.findall(run_cell) or [""])[0]
1306
- if not run_id and run_folder in folder_to_run:
1307
- run_id = folder_to_run[run_folder]
1308
- run_node = nodes.get(nid("run", run_id)) if run_id else None
1309
- profile = (run_node or {}).get("profile", "")
1310
- ident = f"{row['date']}-{row['platform']}-{run_id or 'unknown'}"
1311
- add_node(
1312
- node(
1313
- "published",
1314
- ident,
1315
- row["url"],
1316
- "",
1317
- {
1318
- "date": row["date"],
1319
- "platform": row["platform"],
1320
- "url": row["url"],
1321
- "run_id": run_id,
1322
- "run_folder": run_folder,
1323
- "profile": profile,
1324
- "topic_id": (run_node or {}).get("topic_id", "") or "",
1325
- "pillar": row["pillar"],
1326
- "swipe": row["swipe"],
1327
- "atoms": row["atoms"],
1328
- "claim": row["claim"],
1329
- "result": row["result"],
1330
- "notes": row["notes"],
1331
- "reviewable": row["result"] in ("win", "flat", "loss"),
1332
- "obsidian": obsidian_uri("published/_index.md"),
1333
- "path": "published/_index.md",
1334
- },
1335
- )
1336
- )
1337
- if run_id:
1338
- edge(nid("published", ident), nid("run", run_id), "from_run")
1339
- for sid in field_ids(row["swipe"], "swipe"):
1340
- edge(nid("published", ident), nid("swipe", sid), "uses")
1341
- for aid in field_ids(row["atoms"], "atom"):
1342
- edge(nid("published", ident), nid("atom", aid), "uses")
1343
- for cid in field_ids(row["claim"], "claim"):
1344
- edge(nid("published", ident), nid("claim", cid), "uses")
1345
-
1346
- # Capture → source_url from Raw + engagement from entry/raw notes
1347
- for n in list(nodes.values()):
1348
- if n["kind"] != "capture":
1349
- continue
1350
- raw_node = None
1351
- for e in edges:
1352
- if e["rel"] != "has_raw" or e["from"] != n["id"]:
1353
- continue
1354
- raw_node = nodes.get(e["to"])
1355
- if raw_node and raw_node.get("source_url"):
1356
- n["source_url"] = raw_node["source_url"]
1357
- n["raw_id"] = raw_node["ident"]
1358
- break
1359
- entry_text = read(n["path"]) if n.get("path") and (ROOT / n["path"]).exists() else ""
1360
- raw_text = ""
1361
- raw_path = ""
1362
- if raw_node and raw_node.get("path"):
1363
- raw_path = raw_node["path"]
1364
- elif n.get("raw_path"):
1365
- raw_path = n["raw_path"] if str(n["raw_path"]).endswith(".md") else f'{n["raw_path"]}.md'
1366
- if raw_path and (ROOT / raw_path).exists():
1367
- raw_text = read(raw_path)
1368
- eng = merge_engagement(
1369
- parse_engagement_snapshot(entry_text),
1370
- parse_engagement_snapshot(raw_text),
1371
- )
1372
- for k, v in eng.items():
1373
- if v:
1374
- n[k] = v
1375
-
1376
- # drop edges to missing nodes
1377
- edges = [e for e in edges if e["from"] in nodes and e["to"] in nodes]
1378
- seen = set()
1379
- uniq = []
1380
- for e in edges:
1381
- k = (e["from"], e["to"], e["rel"])
1382
- if k not in seen:
1383
- seen.add(k)
1384
- uniq.append(e)
1385
-
1386
- run_uses: dict[str, set[str]] = {}
1387
- for e in uniq:
1388
- if e["rel"] != "uses":
1389
- continue
1390
- src = nodes.get(e["from"])
1391
- dst = e["to"]
1392
- if not src:
1393
- continue
1394
- if src["kind"] == "run":
1395
- run_uses.setdefault(dst, set()).add(src["id"])
1396
- elif src["kind"] == "published":
1397
- run_uses.setdefault(dst, set()).add(nid("run", src["run_id"]) if src.get("run_id") else src["id"])
1398
- for n in nodes.values():
1399
- if n["kind"] in ("swipe", "atom", "claim"):
1400
- n["refs"] = len(run_uses.get(n["id"], ()))
1401
-
1402
- # flat pillars: prefer first profile for legacy callers; JS uses pillars_by_profile
1403
- flat_pillars = {}
1404
- for pid in sorted(pillars_by_profile.keys()):
1405
- for k, v in pillars_by_profile[pid].items():
1406
- flat_pillars.setdefault(k, v)
1407
-
1408
- return {
1409
- "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
1410
- "vault": str(ROOT),
1411
- "profiles": profiles,
1412
- "accounts": accounts,
1413
- "pillars_by_profile": pillars_by_profile,
1414
- "pillars": flat_pillars,
1415
- "needs_index_present": (ROOT / "needs" / "_index.md").exists(),
1416
- "hits_index_present": (ROOT / "hits" / "_index.md").exists(),
1417
- "products_index_present": (ROOT / "products" / "_index.md").exists(),
1418
- "keywords": keywords,
1419
- "nodes": list(nodes.values()),
1420
- "edges": uniq,
1421
- }
1422
-
1423
-
1424
- def _self_check() -> None:
1425
- # ponytail: tiny parser asserts; expand if capture/raw formats drift again.
1426
- d = parse_engagement_snapshot(
1427
- "- **engagement snapshot:** likes 113 / replies 18 / bookmarks 17 / views 9574 @ 2026-08-30\n"
1428
- )
1429
- assert d == {
1430
- "src_likes": "113",
1431
- "src_replies": "18",
1432
- "src_bookmarks": "17",
1433
- "src_views": "9574",
1434
- "engagement_at": "2026-08-30",
1435
- }, d
1436
- c = parse_engagement_snapshot(
1437
- "6. **Engagement snapshot (fetch 2026-08-25):** ~92.9k views · 1086 likes · 1380 bookmarks · 738 replies · 70 reposts.\n"
1438
- )
1439
- assert c["src_views"] == "92900" and c["src_likes"] == "1086", c
1440
- assert c["src_bookmarks"] == "1380" and c["src_replies"] == "738", c
1441
- assert c["engagement_at"] == "2026-08-25", c
1442
- r = parse_engagement_snapshot(
1443
- "## Engagement snapshot\n\n- likes: 3\n- replies: 3\n- views: 426\n- bookmarks: 2\n- snapshot_at: 2026-09-13\n"
1444
- )
1445
- assert r["src_likes"] == "3" and r["src_views"] == "426" and r["engagement_at"] == "2026-09-13", r
1446
- m = parse_engagement_snapshot(
1447
- "## Snapshot metrics (at capture)\n\n- Views: ~8091\n- Likes: 103\n- Bookmarks: 29\n- Replies: 20\n"
1448
- )
1449
- assert m["src_views"] == "8091" and m["src_likes"] == "103", m
1450
- assert m["src_bookmarks"] == "29" and m["src_replies"] == "20", m
1451
- print("parse_engagement_snapshot self-check ok")
1452
- def main() -> None:
1453
- graph = build_graph()
1454
- html = TPL.read_text(encoding="utf-8")
1455
- payload = json.dumps(graph, ensure_ascii=False)
1456
- html = html.replace("/*__GRAPH__*/null", payload)
1457
- OUT.write_text(html, encoding="utf-8")
1458
- print(f"wrote {OUT} ({len(graph['nodes'])} nodes, {len(graph['edges'])} edges)")
1459
-
1460
-
1461
- if __name__ == "__main__":
1462
- import sys
1463
-
1464
- if "--self-check" in sys.argv:
1465
- _self_check()
1466
- else:
1467
- main()