@my-life-buddies/cli 0.1.2

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 (158) hide show
  1. package/README.md +314 -0
  2. package/dist/bin/buddy.js +443 -0
  3. package/dist/bin/buddy.js.map +7 -0
  4. package/dist/bin/core.js +5638 -0
  5. package/dist/bin/core.js.map +7 -0
  6. package/dist/bin/preview.js +1429 -0
  7. package/dist/bin/preview.js.map +7 -0
  8. package/dist/web/app.css +508 -0
  9. package/dist/web/app.js +724 -0
  10. package/dist/web/assets/developer-platform-icon.png.base64 +1 -0
  11. package/dist/web/brand.css +100 -0
  12. package/dist/web/index.html +236 -0
  13. package/dist/web/tokens.css +15 -0
  14. package/package.json +45 -0
  15. package/resources/README.md +52 -0
  16. package/resources/agent-template/package-lock.json +1726 -0
  17. package/resources/agent-template/package.json +16 -0
  18. package/resources/brand/README.md +11 -0
  19. package/resources/brand/login-result.css +25 -0
  20. package/resources/brand/logo.png +0 -0
  21. package/resources/brand/tokens.css +100 -0
  22. package/resources/buddy-creator/INSTALL.md +25 -0
  23. package/resources/buddy-creator/PATCHES.md +33 -0
  24. package/resources/buddy-creator/SKILL.md +66 -0
  25. package/resources/buddy-creator/THIRD_PARTY_NOTICES.md +154 -0
  26. package/resources/buddy-creator/agents/openai.yaml +4 -0
  27. package/resources/buddy-creator/assets/licenses/bail.txt +26 -0
  28. package/resources/buddy-creator/assets/licenses/ccount.txt +26 -0
  29. package/resources/buddy-creator/assets/licenses/character-entities-html4.txt +26 -0
  30. package/resources/buddy-creator/assets/licenses/character-entities-legacy.txt +26 -0
  31. package/resources/buddy-creator/assets/licenses/character-entities.txt +26 -0
  32. package/resources/buddy-creator/assets/licenses/character-reference-invalid.txt +26 -0
  33. package/resources/buddy-creator/assets/licenses/comma-separated-tokens.txt +26 -0
  34. package/resources/buddy-creator/assets/licenses/debug.txt +24 -0
  35. package/resources/buddy-creator/assets/licenses/decode-named-character-reference.txt +26 -0
  36. package/resources/buddy-creator/assets/licenses/dequal.txt +25 -0
  37. package/resources/buddy-creator/assets/licenses/devlop.txt +26 -0
  38. package/resources/buddy-creator/assets/licenses/escape-string-regexp.txt +13 -0
  39. package/resources/buddy-creator/assets/licenses/estree-util-is-identifier-name.txt +26 -0
  40. package/resources/buddy-creator/assets/licenses/extend.txt +27 -0
  41. package/resources/buddy-creator/assets/licenses/hast-util-to-jsx-runtime.txt +26 -0
  42. package/resources/buddy-creator/assets/licenses/hast-util-whitespace.txt +26 -0
  43. package/resources/buddy-creator/assets/licenses/html-url-attributes.txt +25 -0
  44. package/resources/buddy-creator/assets/licenses/inline-style-parser.txt +13 -0
  45. package/resources/buddy-creator/assets/licenses/is-alphabetical.txt +26 -0
  46. package/resources/buddy-creator/assets/licenses/is-alphanumerical.txt +26 -0
  47. package/resources/buddy-creator/assets/licenses/is-decimal.txt +26 -0
  48. package/resources/buddy-creator/assets/licenses/is-hexadecimal.txt +26 -0
  49. package/resources/buddy-creator/assets/licenses/is-plain-obj.txt +13 -0
  50. package/resources/buddy-creator/assets/licenses/longest-streak.txt +26 -0
  51. package/resources/buddy-creator/assets/licenses/markdown-table.txt +26 -0
  52. package/resources/buddy-creator/assets/licenses/mdast-util-find-and-replace.txt +26 -0
  53. package/resources/buddy-creator/assets/licenses/mdast-util-from-markdown.txt +26 -0
  54. package/resources/buddy-creator/assets/licenses/mdast-util-gfm-autolink-literal.txt +26 -0
  55. package/resources/buddy-creator/assets/licenses/mdast-util-gfm-footnote.txt +26 -0
  56. package/resources/buddy-creator/assets/licenses/mdast-util-gfm-strikethrough.txt +26 -0
  57. package/resources/buddy-creator/assets/licenses/mdast-util-gfm-table.txt +26 -0
  58. package/resources/buddy-creator/assets/licenses/mdast-util-gfm-task-list-item.txt +26 -0
  59. package/resources/buddy-creator/assets/licenses/mdast-util-gfm.txt +26 -0
  60. package/resources/buddy-creator/assets/licenses/mdast-util-mdx-expression.txt +26 -0
  61. package/resources/buddy-creator/assets/licenses/mdast-util-mdx-jsx.txt +26 -0
  62. package/resources/buddy-creator/assets/licenses/mdast-util-mdxjs-esm.txt +26 -0
  63. package/resources/buddy-creator/assets/licenses/mdast-util-phrasing.txt +27 -0
  64. package/resources/buddy-creator/assets/licenses/mdast-util-to-hast.txt +26 -0
  65. package/resources/buddy-creator/assets/licenses/mdast-util-to-markdown.txt +26 -0
  66. package/resources/buddy-creator/assets/licenses/mdast-util-to-string.txt +26 -0
  67. package/resources/buddy-creator/assets/licenses/micromark-core-commonmark.txt +26 -0
  68. package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-autolink-literal.txt +26 -0
  69. package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-footnote.txt +26 -0
  70. package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-strikethrough.txt +26 -0
  71. package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-table.txt +26 -0
  72. package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-tagfilter.txt +26 -0
  73. package/resources/buddy-creator/assets/licenses/micromark-extension-gfm-task-list-item.txt +26 -0
  74. package/resources/buddy-creator/assets/licenses/micromark-extension-gfm.txt +26 -0
  75. package/resources/buddy-creator/assets/licenses/micromark-factory-destination.txt +26 -0
  76. package/resources/buddy-creator/assets/licenses/micromark-factory-label.txt +26 -0
  77. package/resources/buddy-creator/assets/licenses/micromark-factory-space.txt +26 -0
  78. package/resources/buddy-creator/assets/licenses/micromark-factory-title.txt +26 -0
  79. package/resources/buddy-creator/assets/licenses/micromark-factory-whitespace.txt +26 -0
  80. package/resources/buddy-creator/assets/licenses/micromark-util-character.txt +26 -0
  81. package/resources/buddy-creator/assets/licenses/micromark-util-chunked.txt +26 -0
  82. package/resources/buddy-creator/assets/licenses/micromark-util-classify-character.txt +26 -0
  83. package/resources/buddy-creator/assets/licenses/micromark-util-combine-extensions.txt +26 -0
  84. package/resources/buddy-creator/assets/licenses/micromark-util-decode-numeric-character-reference.txt +26 -0
  85. package/resources/buddy-creator/assets/licenses/micromark-util-decode-string.txt +26 -0
  86. package/resources/buddy-creator/assets/licenses/micromark-util-encode.txt +26 -0
  87. package/resources/buddy-creator/assets/licenses/micromark-util-html-tag-name.txt +26 -0
  88. package/resources/buddy-creator/assets/licenses/micromark-util-normalize-identifier.txt +26 -0
  89. package/resources/buddy-creator/assets/licenses/micromark-util-resolve-all.txt +26 -0
  90. package/resources/buddy-creator/assets/licenses/micromark-util-sanitize-uri.txt +26 -0
  91. package/resources/buddy-creator/assets/licenses/micromark-util-subtokenize.txt +26 -0
  92. package/resources/buddy-creator/assets/licenses/micromark-util-symbol.txt +26 -0
  93. package/resources/buddy-creator/assets/licenses/micromark-util-types.txt +26 -0
  94. package/resources/buddy-creator/assets/licenses/micromark.txt +26 -0
  95. package/resources/buddy-creator/assets/licenses/ms.txt +25 -0
  96. package/resources/buddy-creator/assets/licenses/parse-entities.txt +26 -0
  97. package/resources/buddy-creator/assets/licenses/phosphor-icons--react.txt +25 -0
  98. package/resources/buddy-creator/assets/licenses/property-information.txt +26 -0
  99. package/resources/buddy-creator/assets/licenses/react-dom.txt +25 -0
  100. package/resources/buddy-creator/assets/licenses/react-markdown.txt +25 -0
  101. package/resources/buddy-creator/assets/licenses/react.txt +25 -0
  102. package/resources/buddy-creator/assets/licenses/remark-gfm.txt +26 -0
  103. package/resources/buddy-creator/assets/licenses/remark-parse.txt +25 -0
  104. package/resources/buddy-creator/assets/licenses/remark-rehype.txt +26 -0
  105. package/resources/buddy-creator/assets/licenses/remark-stringify.txt +25 -0
  106. package/resources/buddy-creator/assets/licenses/scheduler.txt +25 -0
  107. package/resources/buddy-creator/assets/licenses/space-separated-tokens.txt +26 -0
  108. package/resources/buddy-creator/assets/licenses/stringify-entities.txt +26 -0
  109. package/resources/buddy-creator/assets/licenses/style-to-js.txt +26 -0
  110. package/resources/buddy-creator/assets/licenses/style-to-object.txt +26 -0
  111. package/resources/buddy-creator/assets/licenses/trim-lines.txt +26 -0
  112. package/resources/buddy-creator/assets/licenses/trough.txt +25 -0
  113. package/resources/buddy-creator/assets/licenses/types--debug.txt +25 -0
  114. package/resources/buddy-creator/assets/licenses/types--estree-jsx.txt +25 -0
  115. package/resources/buddy-creator/assets/licenses/types--estree.txt +25 -0
  116. package/resources/buddy-creator/assets/licenses/types--hast.txt +25 -0
  117. package/resources/buddy-creator/assets/licenses/types--mdast.txt +25 -0
  118. package/resources/buddy-creator/assets/licenses/types--ms.txt +25 -0
  119. package/resources/buddy-creator/assets/licenses/types--unist.txt +25 -0
  120. package/resources/buddy-creator/assets/licenses/ungap--structured-clone.txt +19 -0
  121. package/resources/buddy-creator/assets/licenses/unified.txt +25 -0
  122. package/resources/buddy-creator/assets/licenses/unist-util-is.txt +26 -0
  123. package/resources/buddy-creator/assets/licenses/unist-util-position.txt +26 -0
  124. package/resources/buddy-creator/assets/licenses/unist-util-stringify-position.txt +26 -0
  125. package/resources/buddy-creator/assets/licenses/unist-util-visit-parents.txt +26 -0
  126. package/resources/buddy-creator/assets/licenses/unist-util-visit.txt +26 -0
  127. package/resources/buddy-creator/assets/licenses/vfile-message.txt +26 -0
  128. package/resources/buddy-creator/assets/licenses/vfile.txt +25 -0
  129. package/resources/buddy-creator/assets/licenses/zwitch.txt +26 -0
  130. package/resources/buddy-creator/assets/preview/assets/index-B5VmkoSx.js +39 -0
  131. package/resources/buddy-creator/assets/preview/assets/index-DWKP6b-3.css +4 -0
  132. package/resources/buddy-creator/assets/preview/brand-logo.png +0 -0
  133. package/resources/buddy-creator/assets/preview/brand.css +100 -0
  134. package/resources/buddy-creator/assets/preview/index.html +5 -0
  135. package/resources/buddy-creator/assets/preview/reader.css +165 -0
  136. package/resources/buddy-creator/assets/preview/reader.js +135 -0
  137. package/resources/buddy-creator/references/artifact-schema.md +68 -0
  138. package/resources/buddy-creator/references/catalog.json +401 -0
  139. package/resources/buddy-creator/references/definition.md +24 -0
  140. package/resources/buddy-creator/references/dialogue-examples.md +133 -0
  141. package/resources/buddy-creator/references/host-guide.md +213 -0
  142. package/resources/buddy-creator/references/interview.md +235 -0
  143. package/resources/buddy-creator/references/knowledge.md +40 -0
  144. package/resources/buddy-creator/references/methods.md +77 -0
  145. package/resources/buddy-creator/references/opening.md +14 -0
  146. package/resources/buddy-creator/references/preview-panel.md +66 -0
  147. package/resources/buddy-creator/references/recovery.md +43 -0
  148. package/resources/buddy-creator/references/service.md +74 -0
  149. package/resources/buddy-creator/scripts/buddy.py +108 -0
  150. package/resources/buddy-creator/scripts/buddy_client.py +67 -0
  151. package/resources/buddy-creator/scripts/buddy_core.py +803 -0
  152. package/resources/buddy-creator/scripts/completion.py +240 -0
  153. package/resources/buddy-creator/scripts/interview.py +215 -0
  154. package/resources/buddy-creator/scripts/preview.py +577 -0
  155. package/resources/buddy-creator/scripts/preview_panel.py +80 -0
  156. package/resources/buddy-creator/scripts/sources.py +239 -0
  157. package/resources/buddy-creator/version.json +8 -0
  158. package/resources/buddy-creator.manifest.json +696 -0
@@ -0,0 +1,577 @@
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, "buddy-creator-1.2.0", "buddy-creator-1.1.0", "buddy-creator-1.0.0", "python-trial-1"}
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
+ catalog = _read(ROOT / "references" / "catalog.json")
177
+ cards, chapters = catalog["cards"], catalog["chapters"]
178
+ stage = state["stage"]
179
+ targets = state.get("targets", {})
180
+ artifacts = [_artifact_view(state, a) for a in state.get("artifacts", {}).values()]
181
+ by_id = {a["id"]: a for a in artifacts}
182
+ delivery = state.get("deliveries", {}).get(state.get("currentDeliveryId"), {})
183
+ if delivery.get("resumeDeliveryId"):
184
+ delivery = state.get("deliveries", {}).get(delivery["resumeDeliveryId"], delivery)
185
+ target_id = (delivery.get("question") or {}).get("targetId")
186
+ if cards.get(target_id, {}).get("stage") != stage:
187
+ target_id = None
188
+ confirmation = delivery.get("confirmationTarget") or {}
189
+ requested = [r["id"] for r in confirmation.get("objects", [])
190
+ if confirmation.get("stage") == stage and by_id.get(r.get("id"), {}).get("hash") == r.get("hash")]
191
+ hypotheses = sorted([a for a in artifacts if a["kind"] == "hypothesis"], key=lambda a: a["id"])
192
+ target_object = _object_for_target(state, target_id) if target_id else None
193
+ complete = all(_book_confirmed(state, s, chapters) for s in STAGES)
194
+ focus_ids = [target_object] if target_object else [a_id for a_id in requested
195
+ if by_id[a_id]["status"] in ("pending", "revised")]
196
+ if not focus_ids and not target_id:
197
+ candidate = next((a for a in artifacts if a["stage"] == stage and a["status"] in ("pending", "revised")), None)
198
+ if candidate:
199
+ focus_ids = [candidate["id"]]
200
+ focus = by_id.get(focus_ids[0]) if focus_ids else None
201
+ first_target = next((i for i, c in cards.items() if c["stage"] == stage
202
+ and not i.startswith(("H", "T.", "R00"))
203
+ and targets.get(i, {}).get("status", "unstarted") == "unstarted"), None)
204
+ current_target = target_id or (first_target if not focus and not complete
205
+ and not (stage == "methods" and not hypotheses) else None)
206
+ topic_title = cards.get(current_target, {}).get("title")
207
+ if current_target and current_target.startswith("T."):
208
+ pieces = current_target.split(".")
209
+ topic_title = PATHS.get(pieces[1], "用户路径") + " / " + ("时点", "权益", "表达")[int(pieces[2]) - 1]
210
+ def target_summary(identifier):
211
+ item = targets.get(identifier, {})
212
+ summary = item.get("summary", "")
213
+ gaps = item.get("gaps", [])
214
+ return summary + (("\n\n" if summary else "") + "待补:" + ";".join(gaps) if gaps else "")
215
+
216
+ current = {
217
+ "stage": stage,
218
+ "phase": "创作完成" if complete and not target_id and not focus else _phase(current_target or (focus or {}).get("id"), stage),
219
+ "title": STAGE_LABELS[stage] + "手册" if len(focus_ids) > 1 else
220
+ (focus or {}).get("title") or topic_title or ("四本手册已完成" if complete else "整理方法候选" if stage == "methods" else "整理当前内容"),
221
+ "targetId": current_target,
222
+ "artifactIds": focus_ids,
223
+ "summary": target_summary(current_target) if current_target else delivery.get("blocked", ""),
224
+ "status": "已暂停" if state.get("paused") else "待补充" if delivery.get("blocked") else LABELS[focus["status"]] if focus else "已完成" if complete else "讨论中",
225
+ }
226
+ if focus and focus["kind"] == "hypothesis":
227
+ current["position"] = {"index": next(i + 1 for i, a in enumerate(hypotheses) if a["id"] == focus["id"]), "total": len(hypotheses)}
228
+
229
+ def topic(identifier):
230
+ object_id = _object_for_target(state, identifier)
231
+ artifact = by_id.get(object_id)
232
+ target = targets.get(identifier, {})
233
+ return {"id": identifier,
234
+ "title": artifact["title"] if artifact and artifact["kind"] == "hypothesis" else cards.get(identifier, {}).get("title", identifier),
235
+ "summary": target_summary(identifier), "artifactId": object_id,
236
+ "current": identifier == current_target or bool(object_id and object_id in focus_ids),
237
+ "status": LABELS[artifact["status"]] if artifact else "已记录" if target.get("status") == "sufficient"
238
+ else "已跳过" if target.get("status") == "skipped" else "待补充" if target.get("summary") or target.get("status") in ("uncertain", "exhausted") else "未开始"}
239
+
240
+ stages = []
241
+ for index, stage_id in enumerate(STAGES):
242
+ identifiers = [i for i, c in cards.items() if c["stage"] == stage_id]
243
+ groups = []
244
+ if stage_id == "methods":
245
+ groups.extend([
246
+ {"id": "candidates", "title": "方法候选", "topics": [topic("H" + a["id"].split("H")[-1].zfill(2)) for a in hypotheses]},
247
+ {"id": "base", "title": "基础情境", "topics": [topic(i) for i in identifiers if i.startswith("M")]},
248
+ {"id": "extended", "title": "拓展情境", "topics": [topic(i) for i in identifiers if i.startswith("E")]},
249
+ ])
250
+ elif stage_id == "service":
251
+ groups.append({"id": "planning", "title": "服务规划", "topics": [topic(i) for i in identifiers if i.startswith("S")]})
252
+ path_topics = []
253
+ for path_id, title in PATHS.items():
254
+ artifact = by_id.get("transition." + path_id)
255
+ path_topics.append({"id": "path." + path_id, "title": title, "summary": "",
256
+ "artifactId": artifact["id"] if artifact else None,
257
+ "current": bool(current_target and current_target.startswith("T." + path_id + ".")) or "transition." + path_id in focus_ids,
258
+ "status": LABELS[artifact["status"]] if artifact else "系统固定" if path_id == "maintenance-paid" else "未开始"})
259
+ groups.append({"id": "paths", "title": "用户路径", "topics": path_topics})
260
+ elif stage_id == "knowledge":
261
+ groups.append({"id": "interview", "title": "知识整理", "topics": [topic(i) for i in identifiers]})
262
+ book_topics = []
263
+ for chapter_index, title in enumerate(chapters[stage_id]):
264
+ identifier = stage_id + "." + str(chapter_index + 1)
265
+ artifact = by_id.get(identifier)
266
+ target_key = "D" + str(chapter_index + 1).zfill(2) if stage_id == "definition" else None
267
+ target = targets.get(target_key, {})
268
+ book_topics.append({"id": identifier, "title": title, "summary": target_summary(target_key) if target_key else "",
269
+ "artifactId": artifact["id"] if artifact else None,
270
+ "current": identifier in focus_ids or bool(target_key and target_key == current_target),
271
+ "status": LABELS[artifact["status"]] if artifact else "已记录" if target.get("status") == "sufficient" else "待补充" if target.get("summary") else "待形成"})
272
+ groups.append({"id": "booklet", "title": "Booklet", "topics": book_topics})
273
+ members = [a for a in artifacts if a["stage"] == stage_id]
274
+ status = ("已确认" if _book_confirmed(state, stage_id, chapters)
275
+ else "待重新确认" if any(a["status"] == "revised" for a in members)
276
+ else "待确认" if any(a["kind"] == "chapter" for a in members)
277
+ else "进行中" if stage_id == stage or any(targets.get(i, {}).get("status", "unstarted") != "unstarted" for i in identifiers)
278
+ else "未开始")
279
+ stages.append({"id": stage_id, "number": index + 1, "title": STAGE_LABELS[stage_id],
280
+ "current": stage_id == stage, "status": status, "groups": groups,
281
+ "confirmedChapters": sum(a["kind"] == "chapter" and a["status"] == "confirmed" for a in members),
282
+ "totalChapters": len(chapters[stage_id])})
283
+ pending_id = state.get("pendingTurnId")
284
+ drafts = [d for d in state.get("drafts", []) if pending_id and d.get("turnId") == pending_id
285
+ and state.get("turns", {}).get(pending_id, {}).get("status") == "working"]
286
+ drafts.sort(key=lambda d: d.get("sequence", 0))
287
+ visible_drafts = []
288
+ for draft in drafts[-1:]:
289
+ visible_drafts.append({"id": draft.get("id", "draft." + pending_id), "stepId": pending_id,
290
+ "title": draft.get("title", "手册草稿"), "markdown": draft.get("markdown", ""),
291
+ "hash": draft.get("hash") or hashlib.sha256(draft.get("markdown", "").encode("utf-8")).hexdigest()})
292
+ sources = []
293
+ for source in state.get("sources", {}).values():
294
+ # Source processing error objects may contain local paths. Keep the
295
+ # browser message actionable but generic; details stay in the main chat.
296
+ sources.append({"id": source["id"], "title": source.get("title", "知识资料"),
297
+ "kind": source.get("kind", "file"), "status": source.get("status", "failed"),
298
+ "version": source.get("version"), "warnings": [],
299
+ "error": "资料尚未完整处理,请在主对话中补充或重试。" if source.get("status") == "failed" else None})
300
+ completion_view = None
301
+ try:
302
+ import completion
303
+ completion_view = completion.snapshot(workspace, state)
304
+ except (OSError, ValueError, ImportError):
305
+ if complete:
306
+ completion_view = {"revision": state["revision"], "status": "failed", "message": "四册已确认,成果暂时无法读取。"}
307
+ if completion_view:
308
+ completion_view = {k: completion_view.get(k) for k in ("revision", "status", "message")}
309
+ return {"buddyId": state["buddyId"], "revision": state["revision"], "stage": stage,
310
+ "paused": bool(state.get("paused")), "activity": _activity(state, drafts),
311
+ "completion": completion_view, "current": current, "stages": stages,
312
+ "artifacts": artifacts, "sources": sources, "drafts": visible_drafts}
313
+
314
+
315
+ def _endpoint(workspace, workspace_id):
316
+ endpoint = _optional(workspace / "preview-endpoint.json")
317
+ if endpoint is not None and not (isinstance(endpoint, dict) and endpoint.get("version") == 1
318
+ and endpoint.get("workspaceId") == workspace_id and type(endpoint.get("port")) is int
319
+ and 1 <= endpoint["port"] <= 65535
320
+ and isinstance(endpoint.get("token"), str) and re.fullmatch(r"[a-f0-9]{48}", endpoint["token"])):
321
+ _fail("PREVIEW_ADDRESS_INVALID", "固定预览地址无效或属于其他搭子;已保留原记录。")
322
+ return endpoint
323
+
324
+
325
+ def _url(endpoint):
326
+ return "http://127.0.0.1:%s/p/%s/" % (endpoint["port"], endpoint["token"])
327
+
328
+
329
+ def _listening(port):
330
+ try:
331
+ with socket.create_connection(("127.0.0.1", port), timeout=0.5):
332
+ return True
333
+ except OSError:
334
+ return False
335
+
336
+
337
+ def _identity(endpoint, workspace_id, record):
338
+ class NoRedirect(HTTPRedirectHandler):
339
+ def redirect_request(self, request, response, code, message, headers, new_url):
340
+ return None
341
+
342
+ try:
343
+ # Loopback health requests must not pass through configured HTTP proxies.
344
+ with build_opener(ProxyHandler({}), NoRedirect()).open(Request(_url(endpoint) + "api/health"), timeout=1.2) as response:
345
+ if response.geturl() != _url(endpoint) + "api/health":
346
+ _fail("PREVIEW_IDENTITY_MISMATCH", "预览身份不匹配,未接管此端口。")
347
+ identity = json.loads(response.read(8192).decode("utf-8"))
348
+ except (OSError, HTTPError, URLError, ValueError):
349
+ return False
350
+ if not isinstance(identity, dict) or identity.get("workspaceId") != workspace_id or identity.get("runtimeVersion") not in COMPATIBLE_RUNTIME_VERSIONS:
351
+ _fail("PREVIEW_IDENTITY_MISMATCH", "端口上的服务不属于当前版本的搭子预览,未接管或停止它。")
352
+ if not record or identity.get("pid") != record.get("pid") or record.get("workspaceId") != workspace_id or record.get("url") != _url(endpoint):
353
+ _fail("PREVIEW_IDENTITY_MISMATCH", "预览进程记录与端口上的服务不一致,未接管或停止它。")
354
+ return True
355
+
356
+
357
+ def _alive(pid):
358
+ if type(pid) is not int or pid <= 0:
359
+ return True
360
+ try:
361
+ os.kill(pid, 0)
362
+ return True
363
+ except ProcessLookupError:
364
+ return False
365
+ except (PermissionError, OSError):
366
+ return True
367
+
368
+
369
+ @contextlib.contextmanager
370
+ def _lock(workspace, name):
371
+ path = workspace / name
372
+ owner = {"pid": os.getpid(), "token": secrets.token_hex(12)}
373
+ deadline = time.monotonic() + 12
374
+ while True:
375
+ try:
376
+ descriptor = os.open(str(path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
377
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
378
+ json.dump(owner, handle)
379
+ handle.flush()
380
+ os.fsync(handle.fileno())
381
+ break
382
+ except FileExistsError:
383
+ try:
384
+ observed = _read(path)
385
+ if not _alive(observed.get("pid")) and _read(path) == observed:
386
+ path.unlink()
387
+ continue
388
+ except (OSError, ValueError, AttributeError):
389
+ pass
390
+ if time.monotonic() >= deadline:
391
+ _fail("PREVIEW_BUSY", "预览启动正在进行或锁记录需检查,请稍后重试。")
392
+ time.sleep(0.1)
393
+ try:
394
+ yield
395
+ finally:
396
+ with contextlib.suppress(OSError, ValueError):
397
+ if _read(path) == owner:
398
+ path.unlink()
399
+
400
+
401
+ def start(workspace):
402
+ workspace = Path(workspace).resolve()
403
+ state = _read(workspace / "state.json")
404
+ workspace_id = state["workspaceId"]
405
+ with _lock(workspace, ".preview-lifecycle.lock"):
406
+ endpoint = _endpoint(workspace, workspace_id)
407
+ record = _optional(workspace / "preview-server.json")
408
+ if endpoint and _identity(endpoint, workspace_id, record):
409
+ return {"url": _url(endpoint), "readonly": True, "addressPolicy": "stable_per_workspace"}
410
+ if endpoint and _listening(endpoint["port"]):
411
+ _fail("PREVIEW_PORT_IN_USE", "当前搭子的固定端口已被其他进程占用;保留原地址,未切换端口或停止其他程序。")
412
+ arguments = [sys.executable, str(ROOT / "scripts" / "buddy.py"), "serve", "--workspace", str(workspace)]
413
+ options = {"stdin": subprocess.DEVNULL, "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL,
414
+ "cwd": str(ROOT), "close_fds": True}
415
+ if os.name == "nt":
416
+ options["creationflags"] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
417
+ else:
418
+ options["start_new_session"] = True
419
+ process = subprocess.Popen(arguments, **options)
420
+ for _ in range(80):
421
+ time.sleep(0.1)
422
+ if process.poll() is not None:
423
+ _fail("PREVIEW_START_FAILED", "本地预览未启动。可在主对话中重试打开;固定地址与已保存内容保持不变。")
424
+ endpoint = _endpoint(workspace, workspace_id)
425
+ record = _optional(workspace / "preview-server.json")
426
+ if endpoint and record and record.get("pid") == process.pid and _identity(endpoint, workspace_id, record):
427
+ return {"url": _url(endpoint), "readonly": True, "addressPolicy": "stable_per_workspace"}
428
+ # Only stop the still-running child created by this invocation. Never
429
+ # signal a PID recovered from disk or another listener on the port.
430
+ if process.poll() is None:
431
+ process.terminate()
432
+ _fail("PREVIEW_START_FAILED", "本地预览启动超时;请稍后重试,固定地址已保留。")
433
+
434
+
435
+ def serve(workspace):
436
+ workspace = Path(workspace).resolve()
437
+ state = _read(workspace / "state.json")
438
+ workspace_id = state["workspaceId"]
439
+ assets = (ROOT / "assets" / "preview").resolve()
440
+ if not (assets / "index.html").is_file():
441
+ _fail("PREVIEW_ASSETS_MISSING", "安装包中的预览文件缺失,请重新解压完整安装包。")
442
+ endpoint = _endpoint(workspace, workspace_id)
443
+ token = endpoint["token"] if endpoint else secrets.token_hex(24)
444
+ base = "/p/" + token + "/"
445
+ streams = threading.BoundedSemaphore(16)
446
+
447
+ class Handler(BaseHTTPRequestHandler):
448
+ protocol_version = "HTTP/1.1"
449
+ server_version = "BuddyPreview"
450
+ sys_version = ""
451
+
452
+ def log_message(self, format, *args):
453
+ pass
454
+
455
+ def send_error(self, code, message=None, explain=None):
456
+ self.reply(code, "请求无法处理。".encode("utf-8"), "text/plain; charset=utf-8")
457
+
458
+ def reply(self, code, body=b"", content_type="text/plain; charset=utf-8"):
459
+ self.send_response(code)
460
+ self.headers_for(content_type)
461
+ self.send_header("Content-Length", str(len(body)))
462
+ self.send_header("Connection", "close")
463
+ self.end_headers()
464
+ self.wfile.write(body)
465
+ self.close_connection = True
466
+
467
+ def headers_for(self, content_type):
468
+ self.send_header("Content-Type", content_type)
469
+ self.send_header("Cache-Control", "no-store")
470
+ self.send_header("X-Content-Type-Options", "nosniff")
471
+ self.send_header("Referrer-Policy", "no-referrer")
472
+ 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 'none'")
473
+
474
+ def do_GET(self):
475
+ expected_host = "127.0.0.1:" + str(self.server.server_port)
476
+ if self.headers.get("Host") != expected_host or self.headers.get("Origin") not in (None, "http://" + expected_host):
477
+ self.reply(403)
478
+ return
479
+ route_path = urlsplit(self.path)
480
+ if route_path.scheme or route_path.netloc or not route_path.path.startswith(base):
481
+ self.reply(404)
482
+ return
483
+ route = unquote(route_path.path[len(base):])
484
+ try:
485
+ if route == "api/health":
486
+ self.json_reply({"workspaceId": workspace_id, "pid": os.getpid(), "runtimeVersion": RUNTIME_VERSION})
487
+ elif route == "api/snapshot":
488
+ self.json_reply(snapshot(workspace))
489
+ elif route == "api/events":
490
+ self.events()
491
+ else:
492
+ file = (assets / (route or "index.html")).resolve()
493
+ if assets not in file.parents or not file.is_file():
494
+ self.reply(404)
495
+ return
496
+ content_type = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
497
+ ".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")
498
+ self.reply(200, file.read_bytes(), content_type)
499
+ except (BrokenPipeError, ConnectionResetError, TimeoutError):
500
+ self.close_connection = True
501
+ except Exception:
502
+ self.reply(503, "内容暂时无法读取,请保留页面并回到主对话检查。".encode("utf-8"))
503
+
504
+ def json_reply(self, data):
505
+ self.reply(200, json.dumps(data, ensure_ascii=False, separators=(",", ":")).encode("utf-8"), "application/json; charset=utf-8")
506
+
507
+ def events(self):
508
+ if not streams.acquire(blocking=False):
509
+ self.reply(503)
510
+ return
511
+ try:
512
+ self.send_response(200)
513
+ self.headers_for("text/event-stream; charset=utf-8")
514
+ self.send_header("Connection", "keep-alive")
515
+ self.end_headers()
516
+ self.connection.settimeout(15)
517
+ self.wfile.write(b"event: ready\ndata: {}\n\n")
518
+ self.wfile.flush()
519
+ previous = None
520
+ unavailable = False
521
+ while True:
522
+ try:
523
+ current = hashlib.sha256(json.dumps(snapshot(workspace), ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()
524
+ if current != previous or unavailable:
525
+ payload = ('event: changed\ndata: {"version":"' + current + '"}\n\n').encode("utf-8")
526
+ previous, unavailable = current, False
527
+ else:
528
+ payload = b": heartbeat\n\n"
529
+ except Exception:
530
+ payload = b"event: unavailable\ndata: {}\n\n"
531
+ unavailable = True
532
+ self.wfile.write(payload)
533
+ self.wfile.flush()
534
+ time.sleep(1)
535
+ except (OSError, TimeoutError):
536
+ self.close_connection = True
537
+ finally:
538
+ streams.release()
539
+
540
+ def readonly(self):
541
+ self.reply(405, "预览只读,请回到主对话修改。".encode("utf-8"))
542
+
543
+ do_POST = readonly
544
+ do_PUT = readonly
545
+ do_PATCH = readonly
546
+ do_DELETE = readonly
547
+ do_HEAD = readonly
548
+ do_OPTIONS = readonly
549
+
550
+ class Server(ThreadingHTTPServer):
551
+ daemon_threads = True
552
+
553
+ def handle_error(self, request, client_address):
554
+ # HTTP errors must never print archive paths, input or tracebacks.
555
+ pass
556
+
557
+ with _lock(workspace, ".preview-binding.lock"):
558
+ endpoint = _endpoint(workspace, workspace_id)
559
+ if endpoint:
560
+ token = endpoint["token"]
561
+ base = "/p/" + token + "/"
562
+ try:
563
+ server = Server(("127.0.0.1", endpoint["port"] if endpoint else 0), Handler)
564
+ except OSError:
565
+ _fail("PREVIEW_PORT_IN_USE", "当前搭子的固定端口无法使用;未切换地址或停止其他程序。")
566
+ endpoint = {"version": 1, "workspaceId": workspace_id, "port": server.server_port, "token": token}
567
+ try:
568
+ _write(workspace / "preview-endpoint.json", endpoint)
569
+ _write(workspace / "preview-server.json", {"workspaceId": workspace_id, "pid": os.getpid(),
570
+ "url": _url(endpoint), "runtimeVersion": RUNTIME_VERSION})
571
+ except Exception:
572
+ server.server_close()
573
+ raise
574
+ try:
575
+ server.serve_forever(poll_interval=0.5)
576
+ finally:
577
+ server.server_close()
@@ -0,0 +1,80 @@
1
+ """Host-observed panel presence and a durable, at-most-once reopen offer.
2
+
3
+ This module cannot see browser tabs. It records the host's observations;
4
+ it never opens a UI or interprets an interview answer as consent.
5
+ """
6
+ import json
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+ import buddy_core as core
11
+
12
+ VISIBILITIES = {'open', 'closed', 'unknown'}
13
+ REMINDERS = {'not_offered', 'offered', 'accepted', 'declined', 'ignored'}
14
+ SOURCES = {'host_inventory', 'user_report', 'unavailable'}
15
+
16
+
17
+ def _load(path, workspace_id):
18
+ if not path.exists():
19
+ return {'workspaceId': workspace_id, 'visibility': 'unknown', 'source': 'unavailable',
20
+ 'observedAt': None, 'reminder': 'not_offered', 'offeredAt': None}
21
+ try:
22
+ panel = json.loads(path.read_text(encoding='utf-8'))
23
+ valid = (isinstance(panel, dict) and panel.get('workspaceId') == workspace_id
24
+ and panel.get('visibility') in VISIBILITIES and panel.get('source') in SOURCES
25
+ and panel.get('reminder') in REMINDERS)
26
+ except (OSError, ValueError, TypeError):
27
+ valid = False
28
+ core.require(valid, 'PREVIEW_PANEL_STATE', '面板提醒记录无法读取或不属于本作品;保留记录,停止主动询问。用户仍可明确要求打开面板。')
29
+ return panel
30
+
31
+
32
+ def _freshly_closed(panel):
33
+ if panel['visibility'] != 'closed' or panel['source'] == 'unavailable':
34
+ return False
35
+ try:
36
+ observed = datetime.fromisoformat(panel['observedAt'].replace('Z', '+00:00'))
37
+ return 0 <= (datetime.now(timezone.utc) - observed).total_seconds() <= 120
38
+ except (KeyError, AttributeError, TypeError, ValueError):
39
+ return False
40
+
41
+
42
+ def call(workspace, request):
43
+ core.require(isinstance(request, dict), 'PREVIEW_PANEL_INPUT', '面板请求必须为 JSON 对象。')
44
+ action = request.get('action', 'status')
45
+ core.require(isinstance(action, str) and action in {'status', 'observe', 'offer', 'respond'},
46
+ 'PREVIEW_PANEL_ACTION', 'action 使用 status、observe、offer 或 respond。')
47
+ workspace = Path(workspace).expanduser().resolve()
48
+ with core.workspace_lock(workspace):
49
+ state = core.load(workspace)
50
+ path = workspace / 'preview-panel.json'
51
+ panel = _load(path, state['workspaceId'])
52
+ should_ask = False
53
+ if action == 'observe':
54
+ visibility, source = request.get('visibility'), request.get('source')
55
+ core.require(isinstance(visibility, str) and visibility in VISIBILITIES
56
+ and isinstance(source, str) and source in SOURCES,
57
+ 'PREVIEW_PANEL_OBSERVATION', '提供真实 visibility 和 source;不能用服务连通性推测界面状态。')
58
+ core.require(source != 'unavailable' or visibility == 'unknown',
59
+ 'PREVIEW_PANEL_OBSERVATION', '无法检查界面时只能记录 unknown。')
60
+ panel.update(visibility=visibility, source=source, observedAt=core.now())
61
+ elif action == 'offer':
62
+ # Persist before returning permission to ask. A lost receipt must
63
+ # err on the side of silence, never a second unsolicited question.
64
+ should_ask = panel['reminder'] == 'not_offered' and _freshly_closed(panel)
65
+ if should_ask:
66
+ panel.update(reminder='offered', offeredAt=core.now())
67
+ elif action == 'respond':
68
+ response = request.get('response')
69
+ core.require(isinstance(response, str) and response in {'accepted', 'declined', 'ignored'},
70
+ 'PREVIEW_PANEL_RESPONSE', 'response 使用 accepted、declined 或 ignored。')
71
+ core.require(response != 'ignored' or panel['reminder'] != 'not_offered',
72
+ 'PREVIEW_PANEL_RESPONSE', '尚未询问时不能记为未回应。')
73
+ # Explicit requests/refusals also apply before or after an offer.
74
+ # None of these outcomes re-enables unsolicited offers.
75
+ panel['reminder'] = response
76
+ if action != 'status':
77
+ core.atomic_json(path, panel)
78
+ return {'workspace': str(workspace), 'panel': panel,
79
+ 'canOffer': panel['reminder'] == 'not_offered' and _freshly_closed(panel),
80
+ 'shouldAsk': should_ask}