@my-life-buddies/buddy-creator-skill 1.6.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.
Files changed (159) hide show
  1. package/INSTALL.md +25 -0
  2. package/PATCHES.md +57 -0
  3. package/README.md +76 -0
  4. package/SKILL.md +78 -0
  5. package/THIRD_PARTY_NOTICES.md +154 -0
  6. package/agents/openai.yaml +4 -0
  7. package/assets/licenses/bail.txt +26 -0
  8. package/assets/licenses/ccount.txt +26 -0
  9. package/assets/licenses/character-entities-html4.txt +26 -0
  10. package/assets/licenses/character-entities-legacy.txt +26 -0
  11. package/assets/licenses/character-entities.txt +26 -0
  12. package/assets/licenses/character-reference-invalid.txt +26 -0
  13. package/assets/licenses/comma-separated-tokens.txt +26 -0
  14. package/assets/licenses/debug.txt +24 -0
  15. package/assets/licenses/decode-named-character-reference.txt +26 -0
  16. package/assets/licenses/dequal.txt +25 -0
  17. package/assets/licenses/devlop.txt +26 -0
  18. package/assets/licenses/escape-string-regexp.txt +13 -0
  19. package/assets/licenses/estree-util-is-identifier-name.txt +26 -0
  20. package/assets/licenses/extend.txt +27 -0
  21. package/assets/licenses/hast-util-to-jsx-runtime.txt +26 -0
  22. package/assets/licenses/hast-util-whitespace.txt +26 -0
  23. package/assets/licenses/html-url-attributes.txt +25 -0
  24. package/assets/licenses/inline-style-parser.txt +13 -0
  25. package/assets/licenses/is-alphabetical.txt +26 -0
  26. package/assets/licenses/is-alphanumerical.txt +26 -0
  27. package/assets/licenses/is-decimal.txt +26 -0
  28. package/assets/licenses/is-hexadecimal.txt +26 -0
  29. package/assets/licenses/is-plain-obj.txt +13 -0
  30. package/assets/licenses/longest-streak.txt +26 -0
  31. package/assets/licenses/markdown-table.txt +26 -0
  32. package/assets/licenses/mdast-util-find-and-replace.txt +26 -0
  33. package/assets/licenses/mdast-util-from-markdown.txt +26 -0
  34. package/assets/licenses/mdast-util-gfm-autolink-literal.txt +26 -0
  35. package/assets/licenses/mdast-util-gfm-footnote.txt +26 -0
  36. package/assets/licenses/mdast-util-gfm-strikethrough.txt +26 -0
  37. package/assets/licenses/mdast-util-gfm-table.txt +26 -0
  38. package/assets/licenses/mdast-util-gfm-task-list-item.txt +26 -0
  39. package/assets/licenses/mdast-util-gfm.txt +26 -0
  40. package/assets/licenses/mdast-util-mdx-expression.txt +26 -0
  41. package/assets/licenses/mdast-util-mdx-jsx.txt +26 -0
  42. package/assets/licenses/mdast-util-mdxjs-esm.txt +26 -0
  43. package/assets/licenses/mdast-util-phrasing.txt +27 -0
  44. package/assets/licenses/mdast-util-to-hast.txt +26 -0
  45. package/assets/licenses/mdast-util-to-markdown.txt +26 -0
  46. package/assets/licenses/mdast-util-to-string.txt +26 -0
  47. package/assets/licenses/micromark-core-commonmark.txt +26 -0
  48. package/assets/licenses/micromark-extension-gfm-autolink-literal.txt +26 -0
  49. package/assets/licenses/micromark-extension-gfm-footnote.txt +26 -0
  50. package/assets/licenses/micromark-extension-gfm-strikethrough.txt +26 -0
  51. package/assets/licenses/micromark-extension-gfm-table.txt +26 -0
  52. package/assets/licenses/micromark-extension-gfm-tagfilter.txt +26 -0
  53. package/assets/licenses/micromark-extension-gfm-task-list-item.txt +26 -0
  54. package/assets/licenses/micromark-extension-gfm.txt +26 -0
  55. package/assets/licenses/micromark-factory-destination.txt +26 -0
  56. package/assets/licenses/micromark-factory-label.txt +26 -0
  57. package/assets/licenses/micromark-factory-space.txt +26 -0
  58. package/assets/licenses/micromark-factory-title.txt +26 -0
  59. package/assets/licenses/micromark-factory-whitespace.txt +26 -0
  60. package/assets/licenses/micromark-util-character.txt +26 -0
  61. package/assets/licenses/micromark-util-chunked.txt +26 -0
  62. package/assets/licenses/micromark-util-classify-character.txt +26 -0
  63. package/assets/licenses/micromark-util-combine-extensions.txt +26 -0
  64. package/assets/licenses/micromark-util-decode-numeric-character-reference.txt +26 -0
  65. package/assets/licenses/micromark-util-decode-string.txt +26 -0
  66. package/assets/licenses/micromark-util-encode.txt +26 -0
  67. package/assets/licenses/micromark-util-html-tag-name.txt +26 -0
  68. package/assets/licenses/micromark-util-normalize-identifier.txt +26 -0
  69. package/assets/licenses/micromark-util-resolve-all.txt +26 -0
  70. package/assets/licenses/micromark-util-sanitize-uri.txt +26 -0
  71. package/assets/licenses/micromark-util-subtokenize.txt +26 -0
  72. package/assets/licenses/micromark-util-symbol.txt +26 -0
  73. package/assets/licenses/micromark-util-types.txt +26 -0
  74. package/assets/licenses/micromark.txt +26 -0
  75. package/assets/licenses/ms.txt +25 -0
  76. package/assets/licenses/parse-entities.txt +26 -0
  77. package/assets/licenses/phosphor-icons--react.txt +25 -0
  78. package/assets/licenses/property-information.txt +26 -0
  79. package/assets/licenses/react-dom.txt +25 -0
  80. package/assets/licenses/react-markdown.txt +25 -0
  81. package/assets/licenses/react.txt +25 -0
  82. package/assets/licenses/remark-gfm.txt +26 -0
  83. package/assets/licenses/remark-parse.txt +25 -0
  84. package/assets/licenses/remark-rehype.txt +26 -0
  85. package/assets/licenses/remark-stringify.txt +25 -0
  86. package/assets/licenses/scheduler.txt +25 -0
  87. package/assets/licenses/space-separated-tokens.txt +26 -0
  88. package/assets/licenses/stringify-entities.txt +26 -0
  89. package/assets/licenses/style-to-js.txt +26 -0
  90. package/assets/licenses/style-to-object.txt +26 -0
  91. package/assets/licenses/trim-lines.txt +26 -0
  92. package/assets/licenses/trough.txt +25 -0
  93. package/assets/licenses/types--debug.txt +25 -0
  94. package/assets/licenses/types--estree-jsx.txt +25 -0
  95. package/assets/licenses/types--estree.txt +25 -0
  96. package/assets/licenses/types--hast.txt +25 -0
  97. package/assets/licenses/types--mdast.txt +25 -0
  98. package/assets/licenses/types--ms.txt +25 -0
  99. package/assets/licenses/types--unist.txt +25 -0
  100. package/assets/licenses/ungap--structured-clone.txt +19 -0
  101. package/assets/licenses/unified.txt +25 -0
  102. package/assets/licenses/unist-util-is.txt +26 -0
  103. package/assets/licenses/unist-util-position.txt +26 -0
  104. package/assets/licenses/unist-util-stringify-position.txt +26 -0
  105. package/assets/licenses/unist-util-visit-parents.txt +26 -0
  106. package/assets/licenses/unist-util-visit.txt +26 -0
  107. package/assets/licenses/vfile-message.txt +26 -0
  108. package/assets/licenses/vfile.txt +25 -0
  109. package/assets/licenses/zwitch.txt +26 -0
  110. package/assets/preview/assets/index-B5VmkoSx.js +39 -0
  111. package/assets/preview/assets/index-DWKP6b-3.css +4 -0
  112. package/assets/preview/brand-logo.png +0 -0
  113. package/assets/preview/brand.css +100 -0
  114. package/assets/preview/index.html +7 -0
  115. package/assets/preview/reader.css +165 -0
  116. package/assets/preview/reader.js +135 -0
  117. package/assets/preview/widgets.css +1 -0
  118. package/assets/preview/widgets.js +168 -0
  119. package/assets/widget-reference/conversation.png +0 -0
  120. package/assets/widget-reference/expanded.png +0 -0
  121. package/assets/widget-v2/app.js +62 -0
  122. package/assets/widget-v2/style.css +1 -0
  123. package/assets/widget-v3/app.js +65 -0
  124. package/assets/widget-v3/style.css +103 -0
  125. package/assets/widget-v4/app.js +66 -0
  126. package/assets/widget-v4/style.css +187 -0
  127. package/package.json +37 -0
  128. package/references/artifact-schema.md +70 -0
  129. package/references/catalog.json +401 -0
  130. package/references/cli-workflow.md +15 -0
  131. package/references/definition.md +24 -0
  132. package/references/dialogue-examples.md +133 -0
  133. package/references/host-guide.md +219 -0
  134. package/references/interview.md +235 -0
  135. package/references/knowledge.md +40 -0
  136. package/references/methods-protocol.md +60 -0
  137. package/references/methods.md +98 -0
  138. package/references/opening.md +14 -0
  139. package/references/preview-panel.md +66 -0
  140. package/references/recovery.md +46 -0
  141. package/references/service.md +76 -0
  142. package/references/widgets-protocol.md +83 -0
  143. package/references/widgets.md +99 -0
  144. package/scripts/buddy.py +108 -0
  145. package/scripts/buddy_client.py +67 -0
  146. package/scripts/buddy_core.py +908 -0
  147. package/scripts/completion.py +296 -0
  148. package/scripts/interview.py +218 -0
  149. package/scripts/methods.py +181 -0
  150. package/scripts/preview.py +631 -0
  151. package/scripts/preview_panel.py +80 -0
  152. package/scripts/sources.py +239 -0
  153. package/scripts/widget_render.py +66 -0
  154. package/scripts/widget_render_v1.py +176 -0
  155. package/scripts/widget_render_v2.py +98 -0
  156. package/scripts/widget_render_v3.py +86 -0
  157. package/scripts/widget_render_v4.py +121 -0
  158. package/scripts/widgets.py +227 -0
  159. package/version.json +8 -0
@@ -0,0 +1,631 @@
1
+ """Read-only, local preview for Buddy Creator Skill.
2
+
3
+ The bundled browser application is prebuilt static JavaScript: running it in a
4
+ browser does not require Node.js. Business writes belong exclusively to core.py.
5
+ """
6
+
7
+ import contextlib
8
+ import datetime
9
+ import hashlib
10
+ import json
11
+ import mimetypes
12
+ import os
13
+ from pathlib import Path
14
+ import re
15
+ import secrets
16
+ import socket
17
+ import subprocess
18
+ import sys
19
+ import threading
20
+ import time
21
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
22
+ from urllib.error import HTTPError, URLError
23
+ from urllib.parse import unquote, urlsplit
24
+ from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
25
+
26
+
27
+ ROOT = Path(__file__).resolve().parent.parent
28
+ RUNTIME_VERSION = "buddy-creator-" + json.loads((ROOT / "version.json").read_text(encoding="utf-8"))["version"]
29
+ COMPATIBLE_RUNTIME_VERSIONS = {RUNTIME_VERSION}
30
+ STAGES = ("definition", "knowledge", "methods", "service")
31
+ STAGE_LABELS = dict(zip(STAGES, ("定义", "知识", "方法", "服务")))
32
+ LABELS = {"confirmed": "已确认", "accepted": "已采纳", "rejected": "未采纳",
33
+ "pending": "待确认", "revised": "待重新确认"}
34
+ PATHS = {"acquisition-paid": "体验后订阅", "acquisition-maintenance": "暂不订阅",
35
+ "paid-paid": "续费", "paid-maintenance": "停止续费", "maintenance-paid": "恢复订阅"}
36
+
37
+
38
+ def _read(path):
39
+ with Path(path).open("r", encoding="utf-8") as handle:
40
+ return json.load(handle)
41
+
42
+
43
+ def _optional(path):
44
+ try:
45
+ return _read(path)
46
+ except FileNotFoundError:
47
+ return None
48
+
49
+
50
+ def _write(path, data):
51
+ path = Path(path)
52
+ temporary = path.with_name(path.name + "." + secrets.token_hex(8) + ".tmp")
53
+ try:
54
+ descriptor = os.open(str(temporary), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
55
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
56
+ json.dump(data, handle, ensure_ascii=False, separators=(",", ":"))
57
+ handle.flush()
58
+ os.fsync(handle.fileno())
59
+ os.replace(str(temporary), str(path))
60
+ if os.name != "nt":
61
+ directory = os.open(str(path.parent), os.O_RDONLY)
62
+ try:
63
+ os.fsync(directory)
64
+ finally:
65
+ os.close(directory)
66
+ finally:
67
+ with contextlib.suppress(FileNotFoundError):
68
+ temporary.unlink()
69
+
70
+
71
+ def _fail(code, message):
72
+ raise RuntimeError(code + ": " + message)
73
+
74
+
75
+ def _decision(state, artifact, decision="confirmed"):
76
+ return any(c.get("objectId") == artifact["id"] and c.get("hash") == artifact.get("hash")
77
+ and not c.get("invalidatedBy") and c.get("decision") == decision
78
+ for c in state.get("confirmations", []))
79
+
80
+
81
+ def _status(state, artifact):
82
+ for decision in ("confirmed", "accepted", "rejected"):
83
+ if _decision(state, artifact, decision):
84
+ return decision
85
+ return "revised" if any(c.get("objectId") == artifact["id"]
86
+ for c in state.get("confirmations", [])) else "pending"
87
+
88
+
89
+ def _book_confirmed(state, stage, chapters):
90
+ artifacts = state.get("artifacts", {})
91
+ return bool(chapters[stage]) and all(
92
+ artifacts.get(stage + "." + str(i + 1))
93
+ and _decision(state, artifacts[stage + "." + str(i + 1)])
94
+ for i in range(len(chapters[stage])))
95
+
96
+
97
+ def _object_for_target(state, target_id):
98
+ candidate = None
99
+ if re.fullmatch(r"H\d+", target_id):
100
+ candidate = "hypothesis.H" + str(int(target_id[1:]))
101
+ elif re.fullmatch(r"[ME]\d+", target_id):
102
+ candidate = "scenario." + target_id
103
+ elif target_id.startswith("T."):
104
+ candidate = "transition." + target_id.split(".")[1]
105
+ elif re.fullmatch(r"D\d+", target_id):
106
+ candidate = "definition." + str(int(target_id[1:]))
107
+ return candidate if candidate in state.get("artifacts", {}) else None
108
+
109
+
110
+ def _phase(target_id, stage):
111
+ target_id = target_id or ""
112
+ if target_id.startswith(("hypothesis.", "H")):
113
+ return "候选校准"
114
+ if target_id.startswith(("scenario.M", "M")):
115
+ return "基础情境"
116
+ if target_id.startswith(("scenario.E", "E")):
117
+ return "拓展情境"
118
+ if target_id.startswith(("T.", "transition.")):
119
+ return "用户路径"
120
+ if target_id == "service.blueprint":
121
+ return "服务模式确认"
122
+ if target_id == "R00":
123
+ return "本次优化"
124
+ if target_id == "S00":
125
+ return "规划方式"
126
+ if re.fullmatch(r"(definition|knowledge|methods|service)\.\d+", target_id):
127
+ return "手册确认"
128
+ return {"definition": "创作定义", "knowledge": "知识整理",
129
+ "methods": "候选校准", "service": "服务规划"}[stage]
130
+
131
+
132
+ def _artifact_view(state, artifact):
133
+ # Do not expose dependencies, raw turn input, archives, local paths or host
134
+ # context. Only authored public content and its selected citations are sent.
135
+ view = {key: artifact.get(key) for key in ("id", "stage", "kind", "title", "hash")}
136
+ view.update(markdown=artifact.get("markdown", ""), data=artifact.get("data") or {},
137
+ unresolved=artifact.get("unresolved") or [], status=_status(state, artifact))
138
+ view["evidence"] = [{key: ref[key] for key in ("type", "id", "hash", "locator", "quote")
139
+ if key in ref} for ref in artifact.get("evidence", [])]
140
+ return view
141
+
142
+
143
+ def _time(value):
144
+ try:
145
+ return datetime.datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp()
146
+ except (ValueError, TypeError, OverflowError):
147
+ return 0
148
+
149
+
150
+ def _activity(state, drafts):
151
+ if state.get("paused"):
152
+ return {"mode": "paused", "label": "已暂停"}
153
+ pending = state.get("turns", {}).get(state.get("pendingTurnId"))
154
+ if pending and pending.get("status") == "working":
155
+ last_signal = max([_time(pending.get("updatedAt") or pending.get("createdAt"))]
156
+ + [_time(d.get("updatedAt")) for d in drafts])
157
+ elapsed = max(0, time.time() - last_signal)
158
+ if not last_signal or elapsed >= 120:
159
+ return {"mode": "waiting", "label": "本轮还未完成", "detail": "回主对话查看进展"}
160
+ result = {"mode": "working", "label": "正在整理手册" if drafts else "正在梳理你的想法"}
161
+ if elapsed >= 30:
162
+ result["detail"] = "可先阅读已有内容"
163
+ return result
164
+ selected = state.get("sourcePlan", {}).get("sourceIds", [])
165
+ if any(s.get("status") == "failed" for s in state.get("sources", {}).values() if s.get("id") in selected):
166
+ return {"mode": "attention", "label": "资料需要处理", "detail": "回主对话查看原因"}
167
+ current_delivery = state.get("currentDeliveryId")
168
+ if current_delivery and current_delivery not in state.get("presentations", []):
169
+ return {"mode": "ready", "label": "回复已整理", "detail": "等待主对话展示"}
170
+ return None
171
+
172
+
173
+ def snapshot(workspace, state=None):
174
+ workspace = Path(workspace)
175
+ state = state if state is not None else _read(workspace / "state.json")
176
+ import buddy_core
177
+ import methods
178
+ catalog = buddy_core.catalog(state)
179
+ catalog["cards"] = {key: card for key, card in catalog["cards"].items()
180
+ if card["stage"] != "methods" or methods.active(state, key)}
181
+ cards, chapters = catalog["cards"], catalog["chapters"]
182
+ stage = state["stage"]
183
+ method_conclusion = methods.conclusion(state) if stage == "methods" else None
184
+ targets = state.get("targets", {})
185
+ artifacts = [_artifact_view(state, a) for a in state.get("artifacts", {}).values() if a['stage'] in STAGES]
186
+ by_id = {a["id"]: a for a in artifacts}
187
+ delivery = state.get("deliveries", {}).get(state.get("currentDeliveryId"), {})
188
+ if delivery.get("resumeDeliveryId"):
189
+ delivery = state.get("deliveries", {}).get(delivery["resumeDeliveryId"], delivery)
190
+ target_id = (delivery.get("question") or {}).get("targetId")
191
+ if cards.get(target_id, {}).get("stage") != stage:
192
+ target_id = None
193
+ confirmation = delivery.get("confirmationTarget") or {}
194
+ requested = [r["id"] for r in confirmation.get("objects", [])
195
+ if confirmation.get("stage") == stage and by_id.get(r.get("id"), {}).get("hash") == r.get("hash")]
196
+ hypotheses = sorted([a for a in artifacts if a["kind"] == "hypothesis"], key=lambda a: int(a["id"].split("H")[-1]))
197
+ target_object = _object_for_target(state, target_id) if target_id else None
198
+ complete = all(_book_confirmed(state, s, chapters) for s in STAGES)
199
+ focus_ids = [target_object] if target_object else [a_id for a_id in requested
200
+ if by_id[a_id]["status"] in ("pending", "revised")]
201
+ if not focus_ids and not target_id:
202
+ candidate = next((a for a in artifacts if a["stage"] == stage and a["status"] in ("pending", "revised")
203
+ and (not method_conclusion or a["kind"] == "chapter")), None)
204
+ if candidate:
205
+ focus_ids = [candidate["id"]]
206
+ focus = by_id.get(focus_ids[0]) if focus_ids else None
207
+ first_target = next((i for i, c in cards.items() if c["stage"] == stage
208
+ and not i.startswith(("H", "T.", "R00"))
209
+ and targets.get(i, {}).get("status", "unstarted") == "unstarted"), None)
210
+ current_target = target_id or (first_target if not focus and not complete and not method_conclusion
211
+ and not (stage == "methods" and not hypotheses) else None)
212
+ topic_title = cards.get(current_target, {}).get("title")
213
+ if current_target and current_target.startswith("T."):
214
+ pieces = current_target.split(".")
215
+ topic_title = PATHS.get(pieces[1], "用户路径") + " / " + ("时点", "权益", "表达")[int(pieces[2]) - 1]
216
+ def target_summary(identifier):
217
+ item = targets.get(identifier, {})
218
+ summary = item.get("summary", "")
219
+ gaps = item.get("gaps", [])
220
+ return summary + (("\n\n" if summary else "") + "待补:" + ";".join(gaps) if gaps else "")
221
+
222
+ current = {
223
+ "stage": stage,
224
+ "phase": "创作完成" if complete and not target_id and not focus else _phase(current_target or (focus or {}).get("id"), stage),
225
+ "title": STAGE_LABELS[stage] + "手册" if len(focus_ids) > 1 else
226
+ (focus or {}).get("title") or topic_title or ("四本手册已完成" if complete else "整理方法候选" if stage == "methods" else "整理当前内容"),
227
+ "targetId": current_target,
228
+ "artifactIds": focus_ids,
229
+ "summary": target_summary(current_target) if current_target else delivery.get("blocked", ""),
230
+ "status": "已暂停" if state.get("paused") else "待补充" if delivery.get("blocked") else LABELS[focus["status"]] if focus else "已完成" if complete else "讨论中",
231
+ }
232
+ if method_conclusion and not focus:
233
+ current.update(phase="访谈收口", title="方法访谈已收口", summary=method_conclusion["summary"], status="待整理手册")
234
+ if focus and focus["kind"] == "hypothesis":
235
+ current["position"] = {"index": next(i + 1 for i, a in enumerate(hypotheses) if a["id"] == focus["id"]), "total": len(hypotheses)}
236
+
237
+ def topic(identifier):
238
+ object_id = _object_for_target(state, identifier)
239
+ artifact = by_id.get(object_id)
240
+ target = targets.get(identifier, {})
241
+ return {"id": identifier,
242
+ "title": artifact["title"] if artifact and artifact["kind"] == "hypothesis" else cards.get(identifier, {}).get("title", identifier),
243
+ "summary": target_summary(identifier), "artifactId": object_id,
244
+ "current": identifier == current_target or bool(object_id and object_id in focus_ids),
245
+ "status": LABELS[artifact["status"]] if artifact else "已记录" if target.get("status") == "sufficient"
246
+ else "已跳过" if target.get("status") == "skipped" else "待补充" if target.get("summary") or target.get("status") in ("uncertain", "exhausted") else "未开始"}
247
+
248
+ stages = []
249
+ for index, stage_id in enumerate(STAGES):
250
+ identifiers = [i for i, c in cards.items() if c["stage"] == stage_id]
251
+ groups = []
252
+ if stage_id == "methods":
253
+ record = methods.conclusion(state)
254
+ if record:
255
+ groups.append({"id": "conclusion", "title": "访谈收口", "topics": [{"id": "method-conclusion",
256
+ "title": "覆盖充分" if record["status"] == "sufficient" else "创作者主动结束",
257
+ "summary": record["summary"] + "\n" + ";".join(record["unresolved"]),
258
+ "artifactId": None, "current": False, "status": "已收口"}]})
259
+ groups.extend([
260
+ {"id": "candidates", "title": "方法候选", "topics": [topic("H" + a["id"].split("H")[-1].zfill(2)) for a in hypotheses]},
261
+ {"id": "base", "title": "基础情境", "topics": [topic(i) for i in identifiers if i.startswith("M")]},
262
+ {"id": "extended", "title": "拓展情境", "topics": [topic(i) for i in identifiers if i.startswith("E")]},
263
+ ])
264
+ elif stage_id == "service":
265
+ groups.append({"id": "planning", "title": "服务规划", "topics": [topic(i) for i in identifiers if i.startswith("S")]})
266
+ path_topics = []
267
+ for path_id, title in PATHS.items():
268
+ artifact = by_id.get("transition." + path_id)
269
+ path_topics.append({"id": "path." + path_id, "title": title, "summary": "",
270
+ "artifactId": artifact["id"] if artifact else None,
271
+ "current": bool(current_target and current_target.startswith("T." + path_id + ".")) or "transition." + path_id in focus_ids,
272
+ "status": LABELS[artifact["status"]] if artifact else "系统固定" if path_id == "maintenance-paid" else "未开始"})
273
+ groups.append({"id": "paths", "title": "用户路径", "topics": path_topics})
274
+ elif stage_id == "knowledge":
275
+ groups.append({"id": "interview", "title": "知识整理", "topics": [topic(i) for i in identifiers]})
276
+ book_topics = []
277
+ for chapter_index, title in enumerate(chapters[stage_id]):
278
+ identifier = stage_id + "." + str(chapter_index + 1)
279
+ artifact = by_id.get(identifier)
280
+ target_key = "D" + str(chapter_index + 1).zfill(2) if stage_id == "definition" else None
281
+ target = targets.get(target_key, {})
282
+ book_topics.append({"id": identifier, "title": title, "summary": target_summary(target_key) if target_key else "",
283
+ "artifactId": artifact["id"] if artifact else None,
284
+ "current": identifier in focus_ids or bool(target_key and target_key == current_target),
285
+ "status": LABELS[artifact["status"]] if artifact else "已记录" if target.get("status") == "sufficient" else "待补充" if target.get("summary") else "待形成"})
286
+ groups.append({"id": "booklet", "title": "Booklet", "topics": book_topics})
287
+ members = [a for a in artifacts if a["stage"] == stage_id]
288
+ status = ("已确认" if _book_confirmed(state, stage_id, chapters)
289
+ else "待重新确认" if any(a["status"] == "revised" for a in members)
290
+ else "待确认" if any(a["kind"] == "chapter" for a in members)
291
+ else "进行中" if stage_id == stage or any(targets.get(i, {}).get("status", "unstarted") != "unstarted" for i in identifiers)
292
+ else "未开始")
293
+ stages.append({"id": stage_id, "number": index + 1, "title": STAGE_LABELS[stage_id],
294
+ "current": stage_id == stage, "status": status, "groups": groups,
295
+ "confirmedChapters": sum(a["kind"] == "chapter" and a["status"] == "confirmed" for a in members),
296
+ "totalChapters": len(chapters[stage_id])})
297
+ pending_id = state.get("pendingTurnId")
298
+ drafts = [d for d in state.get("drafts", []) if pending_id and d.get("turnId") == pending_id
299
+ and state.get("turns", {}).get(pending_id, {}).get("status") == "working"]
300
+ drafts.sort(key=lambda d: d.get("sequence", 0))
301
+ visible_drafts = []
302
+ for draft in drafts[-1:]:
303
+ visible_drafts.append({"id": draft.get("id", "draft." + pending_id), "stepId": pending_id,
304
+ "title": draft.get("title", "手册草稿"), "markdown": draft.get("markdown", ""),
305
+ "hash": draft.get("hash") or hashlib.sha256(draft.get("markdown", "").encode("utf-8")).hexdigest()})
306
+ sources = []
307
+ for source in state.get("sources", {}).values():
308
+ # Source processing error objects may contain local paths. Keep the
309
+ # browser message actionable but generic; details stay in the main chat.
310
+ sources.append({"id": source["id"], "title": source.get("title", "知识资料"),
311
+ "kind": source.get("kind", "file"), "status": source.get("status", "failed"),
312
+ "version": source.get("version"), "warnings": [],
313
+ "error": "资料尚未完整处理,请在主对话中补充或重试。" if source.get("status") == "failed" else None})
314
+ completion_view = None
315
+ try:
316
+ import completion
317
+ completion_view = completion.snapshot(workspace, state)
318
+ except (OSError, ValueError, ImportError):
319
+ if complete:
320
+ completion_view = {"revision": state["revision"], "status": "failed", "message": "四册已确认,成果暂时无法读取。"}
321
+ import widgets
322
+ widget_design = widgets.projection(state, workspace)
323
+ if complete and widgets.enabled(state) and not widgets.ready(state):
324
+ current.update(phase='小挂件交互设计', title=widget_design['nextStep'], status='待确认')
325
+ return {"buddyId": state["buddyId"], "revision": state["revision"], "stage": stage,
326
+ "paused": bool(state.get("paused")), "activity": _activity(state, drafts),
327
+ "completion": completion_view, "widgetDesign": widget_design, "current": current, "stages": stages,
328
+ "artifacts": artifacts, "sources": sources, "drafts": visible_drafts}
329
+
330
+
331
+ def _endpoint(workspace, workspace_id):
332
+ endpoint = _optional(workspace / "preview-endpoint.json")
333
+ if endpoint is not None and not (isinstance(endpoint, dict) and endpoint.get("version") == 1
334
+ and endpoint.get("workspaceId") == workspace_id and type(endpoint.get("port")) is int
335
+ and 1 <= endpoint["port"] <= 65535
336
+ and isinstance(endpoint.get("token"), str) and re.fullmatch(r"[a-f0-9]{48}", endpoint["token"])):
337
+ _fail("PREVIEW_ADDRESS_INVALID", "固定预览地址无效或属于其他搭子;已保留原记录。")
338
+ return endpoint
339
+
340
+
341
+ def _url(endpoint):
342
+ return "http://127.0.0.1:%s/p/%s/" % (endpoint["port"], endpoint["token"])
343
+
344
+
345
+ def _listening(port):
346
+ try:
347
+ with socket.create_connection(("127.0.0.1", port), timeout=0.5):
348
+ return True
349
+ except OSError:
350
+ return False
351
+
352
+
353
+ def _identity(endpoint, workspace_id, record):
354
+ class NoRedirect(HTTPRedirectHandler):
355
+ def redirect_request(self, request, response, code, message, headers, new_url):
356
+ return None
357
+
358
+ try:
359
+ # Loopback health requests must not pass through configured HTTP proxies.
360
+ with build_opener(ProxyHandler({}), NoRedirect()).open(Request(_url(endpoint) + "api/health"), timeout=1.2) as response:
361
+ if response.geturl() != _url(endpoint) + "api/health":
362
+ _fail("PREVIEW_IDENTITY_MISMATCH", "预览身份不匹配,未接管此端口。")
363
+ identity = json.loads(response.read(8192).decode("utf-8"))
364
+ except (OSError, HTTPError, URLError, ValueError):
365
+ return False
366
+ if (isinstance(identity, dict) and identity.get("workspaceId") == workspace_id
367
+ and record and identity.get("pid") == record.get("pid") and record.get("workspaceId") == workspace_id
368
+ and record.get("url") == _url(endpoint)
369
+ and identity.get("runtimeVersion") in {"buddy-creator-1.3.1", "buddy-creator-1.3.0", "buddy-creator-1.2.0", "buddy-creator-1.1.0", "buddy-creator-1.0.0", "python-trial-1"}):
370
+ _fail("PREVIEW_UPGRADE_REQUIRED", "当前作品的旧版预览服务仍在运行。由宿主核对该服务后重启,再 open 原工作区;保留固定地址、确认记录和一次性面板提醒。")
371
+ if not isinstance(identity, dict) or identity.get("workspaceId") != workspace_id or identity.get("runtimeVersion") not in COMPATIBLE_RUNTIME_VERSIONS:
372
+ _fail("PREVIEW_IDENTITY_MISMATCH", "端口上的服务不属于当前版本的搭子预览,未接管或停止它。")
373
+ if not record or identity.get("pid") != record.get("pid") or record.get("workspaceId") != workspace_id or record.get("url") != _url(endpoint):
374
+ _fail("PREVIEW_IDENTITY_MISMATCH", "预览进程记录与端口上的服务不一致,未接管或停止它。")
375
+ return True
376
+
377
+
378
+ def _alive(pid):
379
+ if type(pid) is not int or pid <= 0:
380
+ return True
381
+ try:
382
+ os.kill(pid, 0)
383
+ return True
384
+ except ProcessLookupError:
385
+ return False
386
+ except (PermissionError, OSError):
387
+ return True
388
+
389
+
390
+ @contextlib.contextmanager
391
+ def _lock(workspace, name):
392
+ path = workspace / name
393
+ owner = {"pid": os.getpid(), "token": secrets.token_hex(12)}
394
+ deadline = time.monotonic() + 12
395
+ while True:
396
+ try:
397
+ descriptor = os.open(str(path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
398
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
399
+ json.dump(owner, handle)
400
+ handle.flush()
401
+ os.fsync(handle.fileno())
402
+ break
403
+ except FileExistsError:
404
+ try:
405
+ observed = _read(path)
406
+ if not _alive(observed.get("pid")) and _read(path) == observed:
407
+ path.unlink()
408
+ continue
409
+ except (OSError, ValueError, AttributeError):
410
+ pass
411
+ if time.monotonic() >= deadline:
412
+ _fail("PREVIEW_BUSY", "预览启动正在进行或锁记录需检查,请稍后重试。")
413
+ time.sleep(0.1)
414
+ try:
415
+ yield
416
+ finally:
417
+ with contextlib.suppress(OSError, ValueError):
418
+ if _read(path) == owner:
419
+ path.unlink()
420
+
421
+
422
+ def start(workspace):
423
+ workspace = Path(workspace).resolve()
424
+ state = _read(workspace / "state.json")
425
+ workspace_id = state["workspaceId"]
426
+ with _lock(workspace, ".preview-lifecycle.lock"):
427
+ endpoint = _endpoint(workspace, workspace_id)
428
+ record = _optional(workspace / "preview-server.json")
429
+ if endpoint and _identity(endpoint, workspace_id, record):
430
+ return {"url": _url(endpoint), "readonly": True, "addressPolicy": "stable_per_workspace"}
431
+ if endpoint and _listening(endpoint["port"]):
432
+ _fail("PREVIEW_PORT_IN_USE", "当前搭子的固定端口已被其他进程占用;保留原地址,未切换端口或停止其他程序。")
433
+ arguments = [sys.executable, str(ROOT / "scripts" / "buddy.py"), "serve", "--workspace", str(workspace)]
434
+ options = {"stdin": subprocess.DEVNULL, "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL,
435
+ "cwd": str(ROOT), "close_fds": True}
436
+ if os.name == "nt":
437
+ options["creationflags"] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
438
+ else:
439
+ options["start_new_session"] = True
440
+ process = subprocess.Popen(arguments, **options)
441
+ for _ in range(80):
442
+ time.sleep(0.1)
443
+ if process.poll() is not None:
444
+ _fail("PREVIEW_START_FAILED", "本地预览未启动。可在主对话中重试打开;固定地址与已保存内容保持不变。")
445
+ endpoint = _endpoint(workspace, workspace_id)
446
+ record = _optional(workspace / "preview-server.json")
447
+ if endpoint and record and record.get("pid") == process.pid and _identity(endpoint, workspace_id, record):
448
+ return {"url": _url(endpoint), "readonly": True, "addressPolicy": "stable_per_workspace"}
449
+ # Only stop the still-running child created by this invocation. Never
450
+ # signal a PID recovered from disk or another listener on the port.
451
+ if process.poll() is None:
452
+ process.terminate()
453
+ _fail("PREVIEW_START_FAILED", "本地预览启动超时;请稍后重试,固定地址已保留。")
454
+
455
+
456
+ def serve(workspace):
457
+ workspace = Path(workspace).resolve()
458
+ state = _read(workspace / "state.json")
459
+ workspace_id = state["workspaceId"]
460
+ assets = (ROOT / "assets" / "preview").resolve()
461
+ if not (assets / "index.html").is_file():
462
+ _fail("PREVIEW_ASSETS_MISSING", "安装包中的预览文件缺失,请重新解压完整安装包。")
463
+ endpoint = _endpoint(workspace, workspace_id)
464
+ token = endpoint["token"] if endpoint else secrets.token_hex(24)
465
+ base = "/p/" + token + "/"
466
+ streams = threading.BoundedSemaphore(16)
467
+
468
+ class Handler(BaseHTTPRequestHandler):
469
+ protocol_version = "HTTP/1.1"
470
+ server_version = "BuddyPreview"
471
+ sys_version = ""
472
+
473
+ def log_message(self, format, *args):
474
+ pass
475
+
476
+ def send_error(self, code, message=None, explain=None):
477
+ self.reply(code, "请求无法处理。".encode("utf-8"), "text/plain; charset=utf-8")
478
+
479
+ def reply(self, code, body=b"", content_type="text/plain; charset=utf-8"):
480
+ self.send_response(code)
481
+ self.headers_for(content_type)
482
+ self.send_header("Content-Length", str(len(body)))
483
+ self.send_header("Connection", "close")
484
+ self.end_headers()
485
+ self.wfile.write(body)
486
+ self.close_connection = True
487
+
488
+ def headers_for(self, content_type):
489
+ self.send_header("Content-Type", content_type)
490
+ self.send_header("Cache-Control", "no-store")
491
+ self.send_header("X-Content-Type-Options", "nosniff")
492
+ self.send_header("Referrer-Policy", "no-referrer")
493
+ frame = "'self'" if '/widget-assets/' in urlsplit(self.path).path else "'none'"
494
+ self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self'; connect-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors " + frame)
495
+
496
+ def do_GET(self):
497
+ expected_host = "127.0.0.1:" + str(self.server.server_port)
498
+ if self.headers.get("Host") != expected_host or self.headers.get("Origin") not in (None, "http://" + expected_host):
499
+ self.reply(403)
500
+ return
501
+ route_path = urlsplit(self.path)
502
+ if route_path.scheme or route_path.netloc or not route_path.path.startswith(base):
503
+ self.reply(404)
504
+ return
505
+ route = unquote(route_path.path[len(base):])
506
+ try:
507
+ if route == "api/health":
508
+ self.json_reply({"workspaceId": workspace_id, "pid": os.getpid(), "runtimeVersion": RUNTIME_VERSION})
509
+ elif route == "api/snapshot":
510
+ self.json_reply(snapshot(workspace))
511
+ elif route == "api/events":
512
+ self.events()
513
+ elif route.startswith('widget-assets/'):
514
+ import buddy_core
515
+ import widgets
516
+ import widget_render
517
+ pieces = route.split('/')
518
+ state = buddy_core.load(workspace)
519
+ artifact = next((a for a in state['artifacts'].values() if a.get('kind') == 'widget-prototype' and len(pieces) == 3 and a['hash'] == pieces[1]), None)
520
+ if not artifact or widgets.blockers(state, artifact):
521
+ self.reply(404)
522
+ return
523
+ status = widget_render.asset_status(workspace, artifact, state)
524
+ if not status['pass'] or pieces[2] not in {f['name'] for f in status.get('files', [])}:
525
+ self.reply(404)
526
+ return
527
+ file = widget_render.directory(workspace, artifact) / pieces[2]
528
+ self.reply(200, file.read_bytes(), {'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml'}[file.suffix])
529
+ elif route.startswith('deliverables/'):
530
+ import buddy_core
531
+ import completion
532
+ state = buddy_core.load(workspace)
533
+ checked = completion.return_to_caller(workspace, state, {})
534
+ parts = route.split('/', 2)
535
+ if checked.get('directive') != 'creator_complete' or len(parts) != 3 or parts[1] != state['revision']:
536
+ self.reply(404)
537
+ return
538
+ directory = workspace / 'deliverables' / state['revision']
539
+ inventory = _read(directory / 'MANIFEST.json')
540
+ if parts[2] not in {entry['path'] for entry in inventory['files']}:
541
+ self.reply(404)
542
+ return
543
+ file = directory / parts[2]
544
+ self.reply(200, file.read_bytes(), mimetypes.guess_type(str(file))[0] or 'text/plain; charset=utf-8')
545
+ else:
546
+ file = (assets / (route or "index.html")).resolve()
547
+ if assets not in file.parents or not file.is_file():
548
+ self.reply(404)
549
+ return
550
+ content_type = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
551
+ ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".woff2": "font/woff2"}.get(file.suffix, mimetypes.guess_type(str(file))[0] or "application/octet-stream")
552
+ self.reply(200, file.read_bytes(), content_type)
553
+ except (BrokenPipeError, ConnectionResetError, TimeoutError):
554
+ self.close_connection = True
555
+ except Exception:
556
+ self.reply(503, "内容暂时无法读取,请保留页面并回到主对话检查。".encode("utf-8"))
557
+
558
+ def json_reply(self, data):
559
+ self.reply(200, json.dumps(data, ensure_ascii=False, separators=(",", ":")).encode("utf-8"), "application/json; charset=utf-8")
560
+
561
+ def events(self):
562
+ if not streams.acquire(blocking=False):
563
+ self.reply(503)
564
+ return
565
+ try:
566
+ self.send_response(200)
567
+ self.headers_for("text/event-stream; charset=utf-8")
568
+ self.send_header("Connection", "keep-alive")
569
+ self.end_headers()
570
+ self.connection.settimeout(15)
571
+ self.wfile.write(b"event: ready\ndata: {}\n\n")
572
+ self.wfile.flush()
573
+ previous = None
574
+ unavailable = False
575
+ while True:
576
+ try:
577
+ current = hashlib.sha256(json.dumps(snapshot(workspace), ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()
578
+ if current != previous or unavailable:
579
+ payload = ('event: changed\ndata: {"version":"' + current + '"}\n\n').encode("utf-8")
580
+ previous, unavailable = current, False
581
+ else:
582
+ payload = b": heartbeat\n\n"
583
+ except Exception:
584
+ payload = b"event: unavailable\ndata: {}\n\n"
585
+ unavailable = True
586
+ self.wfile.write(payload)
587
+ self.wfile.flush()
588
+ time.sleep(1)
589
+ except (OSError, TimeoutError):
590
+ self.close_connection = True
591
+ finally:
592
+ streams.release()
593
+
594
+ def readonly(self):
595
+ self.reply(405, "预览只读,请回到主对话修改。".encode("utf-8"))
596
+
597
+ do_POST = readonly
598
+ do_PUT = readonly
599
+ do_PATCH = readonly
600
+ do_DELETE = readonly
601
+ do_HEAD = readonly
602
+ do_OPTIONS = readonly
603
+
604
+ class Server(ThreadingHTTPServer):
605
+ daemon_threads = True
606
+
607
+ def handle_error(self, request, client_address):
608
+ # HTTP errors must never print archive paths, input or tracebacks.
609
+ pass
610
+
611
+ with _lock(workspace, ".preview-binding.lock"):
612
+ endpoint = _endpoint(workspace, workspace_id)
613
+ if endpoint:
614
+ token = endpoint["token"]
615
+ base = "/p/" + token + "/"
616
+ try:
617
+ server = Server(("127.0.0.1", endpoint["port"] if endpoint else 0), Handler)
618
+ except OSError:
619
+ _fail("PREVIEW_PORT_IN_USE", "当前搭子的固定端口无法使用;未切换地址或停止其他程序。")
620
+ endpoint = {"version": 1, "workspaceId": workspace_id, "port": server.server_port, "token": token}
621
+ try:
622
+ _write(workspace / "preview-endpoint.json", endpoint)
623
+ _write(workspace / "preview-server.json", {"workspaceId": workspace_id, "pid": os.getpid(),
624
+ "url": _url(endpoint), "runtimeVersion": RUNTIME_VERSION})
625
+ except Exception:
626
+ server.server_close()
627
+ raise
628
+ try:
629
+ server.serve_forever(poll_interval=0.5)
630
+ finally:
631
+ server.server_close()