codebee 0.1.66 → 0.1.68

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 (116) hide show
  1. package/CHANGELOG.md +543 -520
  2. package/LICENSE +21 -21
  3. package/README.md +11 -7
  4. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
  5. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
  6. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  7. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
  8. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  9. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  10. package/app/.playwright-cli/run-detail-after.png +0 -0
  11. package/app/core/acceptance.py +100 -0
  12. package/app/core/aiflavor.py +197 -197
  13. package/app/core/artifacts.py +148 -148
  14. package/app/core/assets.py +110 -0
  15. package/app/core/attachments.py +623 -532
  16. package/app/core/automation.py +758 -758
  17. package/app/core/backup.py +470 -470
  18. package/app/core/beekeeper.py +183 -183
  19. package/app/core/board.py +209 -209
  20. package/app/core/bookmeta.py +930 -796
  21. package/app/core/bookmeta_catalog.py +180 -180
  22. package/app/core/branching.py +137 -137
  23. package/app/core/builtin_agent.py +1234 -1234
  24. package/app/core/capability.py +144 -144
  25. package/app/core/catalog.py +42 -0
  26. package/app/core/chaptersafe.py +32 -32
  27. package/app/core/cleanup.py +303 -303
  28. package/app/core/compaction.py +201 -201
  29. package/app/core/covergen.py +356 -356
  30. package/app/core/defectretro.py +43 -0
  31. package/app/core/diagnostics.py +114 -114
  32. package/app/core/dispatch.py +173 -173
  33. package/app/core/dispatch_log.py +128 -113
  34. package/app/core/env_scrub.py +85 -85
  35. package/app/core/error_codes.py +23 -23
  36. package/app/core/errorlog.py +137 -137
  37. package/app/core/evaluation.py +174 -174
  38. package/app/core/failure_notify.py +290 -290
  39. package/app/core/flows.py +48 -2
  40. package/app/core/goal_service.py +167 -167
  41. package/app/core/health.py +374 -374
  42. package/app/core/history.py +38 -38
  43. package/app/core/hooks.py +162 -162
  44. package/app/core/jobs.py +46 -26
  45. package/app/core/knowledge.py +57 -3
  46. package/app/core/manager.py +2122 -2115
  47. package/app/core/market.py +362 -362
  48. package/app/core/market_remote.py +124 -26
  49. package/app/core/mocks.py +64 -64
  50. package/app/core/modelhub.py +51 -3
  51. package/app/core/notify.py +106 -106
  52. package/app/core/operations.py +228 -0
  53. package/app/core/paihang.py +138 -109
  54. package/app/core/paths.py +2 -2
  55. package/app/core/pipeline.py +221 -12
  56. package/app/core/planner.py +6 -1
  57. package/app/core/portguard.py +111 -111
  58. package/app/core/preview.py +293 -293
  59. package/app/core/publish/__init__.py +7 -7
  60. package/app/core/publish/auto.py +367 -367
  61. package/app/core/publish/browser.py +539 -533
  62. package/app/core/publish/fanqie.py +159 -128
  63. package/app/core/publish/flow.py +440 -432
  64. package/app/core/publish/flows-fanqie-calibrated.json +332 -332
  65. package/app/core/publish/flows-qimao-calibrated.json +7 -3
  66. package/app/core/publish/ledger.py +203 -198
  67. package/app/core/publish/manager.py +692 -623
  68. package/app/core/publish/qimao.py +141 -141
  69. package/app/core/publish/ws.py +139 -139
  70. package/app/core/redact.py +38 -38
  71. package/app/core/repeat_guard.py +129 -129
  72. package/app/core/revisions.py +51 -0
  73. package/app/core/router.py +398 -398
  74. package/app/core/runner.py +2067 -2050
  75. package/app/core/selfupdate.py +384 -384
  76. package/app/core/session_log.py +161 -161
  77. package/app/core/sessions.py +312 -312
  78. package/app/core/settings.py +149 -149
  79. package/app/core/settings_schema.py +262 -262
  80. package/app/core/share_page.py +114 -114
  81. package/app/core/skill_scan.py +122 -114
  82. package/app/core/skillpacks/fanqie-novel.md +80 -80
  83. package/app/core/skillpacks/market/character-bible.md +66 -66
  84. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  85. package/app/core/skillpacks/market/git-workflow.md +57 -57
  86. package/app/core/skillpacks/market/release-notes.md +72 -72
  87. package/app/core/skillpacks/market/weekly-report.md +71 -71
  88. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  89. package/app/core/skills.py +109 -2
  90. package/app/core/step_runner.py +132 -132
  91. package/app/core/store.py +278 -41
  92. package/app/core/task_compile.py +199 -199
  93. package/app/core/telemetry.py +292 -292
  94. package/app/core/tlsctx.py +64 -64
  95. package/app/core/token_meter.py +147 -147
  96. package/app/core/tracing.py +144 -0
  97. package/app/core/upstream.py +41 -41
  98. package/app/core/usage.py +862 -858
  99. package/app/core/volumes.py +389 -355
  100. package/app/core/wxdigest.py +858 -858
  101. package/app/core/zentao.py +2465 -2447
  102. package/app/main.py +2981 -2838
  103. package/app/pick_dialog.py +110 -110
  104. package/app/ui/app.js +14399 -13994
  105. package/app/ui/artdiff.js +74 -0
  106. package/app/ui/board.html +162 -162
  107. package/app/ui/board.js +305 -305
  108. package/app/ui/i18n.js +2770 -2716
  109. package/app/ui/icons/bee.svg +79 -79
  110. package/app/ui/index.html +6 -0
  111. package/app/ui/manifest.json +16 -16
  112. package/app/ui/qrcode.js +2297 -2297
  113. package/app/ui/style.css +96 -24
  114. package/bin/tutti.js +147 -147
  115. package/package.json +46 -46
  116. package/tools/wx_reader.py +261 -261
@@ -1,362 +1,362 @@
1
- # -*- coding: utf-8 -*-
2
- """插件市场(Market):经验库(skills)之上的「发现 + 一键安装」层。
3
-
4
- 分工:skills 负责包的解析 / scope 匹配 / 注入 / 启停(运行机制),市场只负责
5
- 「发现与安装」——把人工维护的包目录(BUILTIN_PACKS)展示出来,用户点一下就把
6
- 包内容写进 data/skillpacks/,之后的一切(frontmatter 解析、按流程类型注入、启停)
7
- 全部复用 skills 的既有机制,市场不自建注入通道。
8
-
9
- 数据约定:
10
- - 目录内容源:app/core/skillpacks/market/<pack_id>.md(与内置包同一棵目录树,
11
- frontmatter 里带 source: market / market_id: <id> 作为安装标记);
12
- - 安装目标:data/skillpacks/market-<pack_id>.md(skills 的用户包目录;market-
13
- 前缀 + frontmatter 标记双保险,绝不覆盖用户自建文件,重名自动避让换名);
14
- - 安装状态:data/market.json(原子写),记录装过哪些包、装到了哪个文件;
15
- 记录丢失时按文件里的 market 标记扫描自愈(installed_ids)。
16
- 卸载只认「market.json 有记录 + 文件带 market 标记」的包;skills 内置包
17
- (app/core/skillpacks/ 下)与无标记的用户自建包一律拒绝删除。
18
- """
19
- from __future__ import annotations
20
-
21
- import json
22
- import re
23
- import shutil
24
- import threading
25
- import time
26
-
27
- from . import paths, skills
28
-
29
- _LOCK = threading.RLock()
30
-
31
- # 目录内容的源目录:与 skills 内置包同一棵目录树(app/core/skillpacks/),子目录分家
32
- _SRC_DIR = paths.APP_DIR / "core" / "skillpacks" / "market"
33
-
34
- # 文件 frontmatter 里的安装标记(本模块自己写的格式,正则足够)
35
- _MARKER_RE = re.compile(r"(?m)^market_id:\s*([A-Za-z0-9_.-]+)\s*$")
36
-
37
-
38
- def _registry_file():
39
- """安装状态文件:每次现取 paths.DATA_DIR(测试重定向后自动跟随)。"""
40
- return paths.DATA_DIR / "market.json"
41
-
42
-
43
- def _user_pack_dir():
44
- """skills 的用户包目录(安装目标),随 paths.DATA_DIR 现取。"""
45
- return paths.DATA_DIR / "skillpacks"
46
-
47
-
48
- def _now():
49
- return time.strftime("%Y-%m-%d %H:%M:%S")
50
-
51
-
52
- # ---------------------------------------------------------------- 包目录(catalog)
53
-
54
- # 可安装的市场包目录:内容源文件在 skillpacks/market/<id>.md,import 时读入 files
55
- # ({安装相对路径: 正文})。category 必须取 skills.LESSON_CATEGORIES 闭集枚举值。
56
- BUILTIN_PACKS = [
57
- {"id": "git-workflow", "name": "Git 提交与分支守则",
58
- "desc": "提交信息格式与粒度、分支模型、force push 与回滚等危险操作红线,附提交前自检清单。",
59
- "category": "流程规范", "scopes": ["*"], "file": "git-workflow.md"},
60
- {"id": "code-risk-checklist", "name": "代码风险自查清单",
61
- "desc": "提测/评审前按事故率过单:边界与异常、资源与并发、注入与泄密、跨平台与回退。",
62
- "category": "流程规范", "scopes": ["*"], "file": "code-risk-checklist.md"},
63
- {"id": "release-notes", "name": "版本发布说明撰写模板",
64
- "desc": "面向用户的更新公告写法:固定六段结构、破坏性变更三要素、可复制模板与反例对照。",
65
- "category": "文笔风格", "scopes": ["*"], "file": "release-notes.md"},
66
- {"id": "weekly-report", "name": "周报/晨报生成器守则",
67
- "desc": "先取材再总结:从任务与运行记录提炼晨报三段、周报四段,量化纪律与可复制模板。",
68
- "category": "流程规范", "scopes": ["*"], "file": "weekly-report.md"},
69
- {"id": "character-bible", "name": "角色小传与人物弧光模板",
70
- "desc": "角色档案模板(欲望/恐惧/语言指纹)、四拍弧光规划与连载防 OOC 纪律。",
71
- "category": "人物塑造", "scopes": ["novel", "serial_novel"], "file": "character-bible.md"},
72
- {"id": "worldview-consistency", "name": "世界观设定一致性台账守则",
73
- "desc": "设定台账五件套、设定变更三步流程、高频吃书场景排查与章前查章后记闭环。",
74
- "category": "一致性", "scopes": ["novel", "serial_novel"], "file": "worldview-consistency.md"},
75
- ]
76
-
77
- # skills 里已内置的经验包 → 市场目录的展示信息(人工标注分类与一句话说明;
78
- # skills 未来新增内置包时落「未分类」兜底,目录仍然可见、不可安装)
79
- _BUILTIN_META = {
80
- "fanqie-novel": {"category": "节奏爽点",
81
- "desc": "算法流量池与完读追读、黄金三章整体验、题材标签匹配、更新纪律与合同要点。"},
82
- "qimao-signing": {"category": "流程规范",
83
- "desc": "七猫签约导向的写作规范:黄金一章、爽点纪律、期待感三源、人物红线与自检清单。"},
84
- }
85
-
86
-
87
- def _read_source(fname):
88
- """读目录内容源文件(限制在本目录内,防路径逃逸);缺失返回空串。"""
89
- try:
90
- p = (_SRC_DIR / fname).resolve()
91
- if _SRC_DIR.resolve() not in p.parents or not p.is_file():
92
- return ""
93
- return p.read_text(encoding="utf-8", errors="replace")
94
- except Exception:
95
- return ""
96
-
97
-
98
- for _p in BUILTIN_PACKS:
99
- # files: {安装相对路径: 内容}——单文件包即一个顶层 .md(装后成为 skills 用户包);
100
- # 未来带子目录相对路径的条目会作为随包资料落到 market-assets/(见 _dest_path)
101
- _p["files"] = {"market-%s.md" % _p["id"]: _read_source(_p["file"])}
102
-
103
-
104
- def _dest_path(pack_id, rel):
105
- """files 的相对路径 → 安装目标:无子目录的落 data/skillpacks/ 顶层(成为可注入
106
- 用户包);带子目录的附加件落 data/skillpacks/market-assets/<id>/(skills 只扫
107
- 顶层 *.md,附加件作为随包资料不参与注入)。"""
108
- rel = str(rel).replace("\\", "/")
109
- if "/" not in rel:
110
- return _user_pack_dir() / rel
111
- return _user_pack_dir() / "market-assets" / pack_id / rel
112
-
113
-
114
- def _is_market_file(path, pack_id):
115
- """文件是否带本包的 market 安装标记(frontmatter 的 source / market_id 两行)。"""
116
- try:
117
- head = path.read_text(encoding="utf-8", errors="replace")[:800]
118
- except OSError:
119
- return False
120
- return "source: market" in head and ("market_id: %s" % pack_id) in head
121
-
122
-
123
- # ---------------------------------------------------------------- 安装状态(持久化)
124
-
125
- def _load_registry():
126
- try:
127
- data = json.loads(_registry_file().read_text(encoding="utf-8"))
128
- return data if isinstance(data, dict) else {}
129
- except Exception:
130
- return {}
131
-
132
-
133
- def _save_registry(data):
134
- f = _registry_file()
135
- f.parent.mkdir(parents=True, exist_ok=True)
136
- tmp = f.with_suffix(".tmp")
137
- tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
138
- tmp.replace(f) # 原子替换(与 skills._save 同一手法)
139
-
140
-
141
- def _scan_marked_files():
142
- """扫 data/skillpacks/*.md 里的 market 安装标记:id → 文件名列表。
143
- market.json 丢失(手工清理/换机)时据此自愈识别已装包。"""
144
- found = {}
145
- try:
146
- for p in sorted(_user_pack_dir().glob("*.md")):
147
- try:
148
- head = p.read_text(encoding="utf-8", errors="replace")[:800]
149
- except OSError:
150
- continue
151
- if "source: market" not in head:
152
- continue
153
- m = _MARKER_RE.search(head)
154
- if m:
155
- found.setdefault(m.group(1), []).append(p.name)
156
- except OSError:
157
- pass
158
- return found
159
-
160
-
161
- def installed_ids():
162
- """市场已安装的包 id 集合 = market.json 记录 ∪ 文件标记扫描(自愈)。"""
163
- with _LOCK:
164
- ids = set((_load_registry().get("installed") or {}).keys())
165
- ids.update(_scan_marked_files().keys())
166
- return ids
167
-
168
-
169
- # ---------------------------------------------------------------- 对外接口
170
-
171
- def view():
172
- """市场目录(给 UI/API):{catalog: [...], categories: [...]}。
173
- 每项带 installed: bool + enabled: bool(已装包的启停态,卡片开关直接
174
- 切换,不用再去经验库翻——对齐 ZCode 插件管理形态)。
175
- 注意 id 双轨:市场 id(git-workflow)≠ skills 用户包 id(user-哈希),
176
- 启停态要经安装记录的 file 路径映射到真实用户包再查。"""
177
- installed = installed_ids()
178
- reg = _load_registry().get("installed") or {}
179
- ups = skills.user_packs()
180
-
181
- def _lookup(pack_id, is_builtin):
182
- """返回 (enabled, 可启停的真实包 id)。内置即市场 id;市场包装成的
183
- 用户包 id 是 user-哈希,必须经安装记录的 file 路径映射。"""
184
- if is_builtin:
185
- return skills._pack_enabled(pack_id), pack_id
186
- rec = reg.get(pack_id) or {}
187
- fhint = str(rec.get("file") or "").replace("\\", "/")
188
- for up in ups:
189
- f = str(up.get("file") or "").replace("\\", "/")
190
- if fhint and (f == fhint or f.endswith("/" + fhint)):
191
- return skills._pack_enabled(up["id"]), up["id"]
192
- return True, pack_id
193
-
194
- items = []
195
- for p in skills.BUILTIN_PACKS: # 已内置:已存在,不可安装
196
- meta = _BUILTIN_META.get(p["id"]) or {}
197
- en, pid = _lookup(p["id"], True)
198
- items.append({"id": p["id"], "name": p["name"],
199
- "desc": meta.get("desc") or p.get("note") or "",
200
- "category": meta.get("category") or skills.LESSON_UNCATEGORIZED,
201
- "scopes": list(p.get("scopes") or ["*"]),
202
- "builtin": True, "installed": True, "installable": False,
203
- "enabled": en, "pack_id": pid,
204
- "chars": len(skills.pack_text(p))})
205
- for p in BUILTIN_PACKS: # 可安装市场包
206
- files = p.get("files") or {}
207
- inst = p["id"] in installed
208
- en, pid = _lookup(p["id"], False) if inst else (False, p["id"])
209
- items.append({"id": p["id"], "name": p["name"], "desc": p["desc"],
210
- "category": p["category"], "scopes": list(p["scopes"]),
211
- "builtin": False, "installed": inst,
212
- "installable": True,
213
- "enabled": en, "pack_id": pid,
214
- "files": sorted(files),
215
- "chars": sum(len(v) for v in files.values())})
216
- cats = list(skills.LESSON_CATEGORIES) # 闭集全枚举先给全(UI 下拉不缺项)
217
- for it in items:
218
- if it["category"] not in cats:
219
- cats.append(it["category"])
220
- return {"catalog": items, "categories": cats}
221
-
222
-
223
- def install(pack_id):
224
- """安装市场包:把 files 写进 skills 的用户包目录(data/skillpacks/),
225
- 之后由 skills 正常解析、按 scopes 注入。返回 (结果 dict, 错误)。
226
-
227
- 已装过(记录在且文件仍是本包的)→ 幂等返回 already=True,不覆盖用户可能的
228
- 定制;记录在但文件丢了/被替换 → 换名重写修复,绝不覆盖无标记的用户文件;
229
- 未知 id 与 skills 内置包 → 报错。
230
- """
231
- if any(p["id"] == pack_id for p in skills.BUILTIN_PACKS):
232
- return None, "「%s」是内置经验包(已存在),无需安装" % pack_id
233
- pack = next((p for p in BUILTIN_PACKS if p["id"] == pack_id), None)
234
- if not pack:
235
- return None, "市场目录中无此包: %s" % pack_id
236
- files = pack.get("files") or {}
237
- if not any(str(v).strip() for v in files.values()):
238
- return None, "包内容缺失(skillpacks/market/%s 不存在或为空)" % pack.get("file")
239
- return install_files(pack_id, pack["name"], files)
240
-
241
-
242
- def install_files(pack_id, name, files, extra=None):
243
- """通用安装入口:任意 {安装相对路径: 文本内容} 写进用户包目录并记账。
244
- 内置市场包(install)与外部目录插件(market_remote)共用这一条落地通道,
245
- 避让 / 安装标记 / market.json 记账 / 幂等纪律完全一致;extra 追加进记账记录
246
- (如外部插件的来源与版本)。
247
-
248
- 装前静态扫描(借鉴 NVIDIA SkillSpector):安装内容扫危险模式,风险行写进
249
- 返回值与记账——提示不拦阻(用户仍可装),但危险必须被看见。"""
250
- if not files or not any(str(v).strip() for v in files.values()):
251
- return None, "包内容为空: %s" % pack_id
252
- # 装前扫描:全文件合并扫一遍(纯内存静态规则)
253
- scan_note = ""
254
- try:
255
- from . import skill_scan
256
- scan_note = skill_scan.scan_summary("\n".join(str(v) for v in files.values()))
257
- except Exception:
258
- scan_note = ""
259
- with _LOCK:
260
- udir = _user_pack_dir()
261
- reg = _load_registry()
262
- installed = reg.setdefault("installed", {})
263
- rec = installed.get(pack_id)
264
- primary = "market-%s.md" % pack_id
265
- target = (rec or {}).get("file") or ""
266
- already = bool(target and _is_market_file(udir / target, pack_id))
267
- if not already:
268
- # 无记录 / 记录在但文件丢了或被替换:主文件选一个不踩用户文件的目标名
269
- target = primary
270
- n = 1
271
- while (udir / target).exists() and not _is_market_file(udir / target, pack_id):
272
- n += 1
273
- target = "market-%s-%d.md" % (pack_id, n)
274
- try:
275
- udir.mkdir(parents=True, exist_ok=True)
276
- written = []
277
- for rel in sorted(files):
278
- # 主文件落避让后的名字;带子目录的附加件按原相对路径落 assets
279
- name_i = target if rel == primary else rel
280
- dest = _dest_path(pack_id, name_i)
281
- dest.parent.mkdir(parents=True, exist_ok=True)
282
- dest.write_text(files[rel], encoding="utf-8")
283
- written.append(name_i)
284
- except OSError as e:
285
- return None, "写入用户技能库失败: %s" % e
286
- record = {"file": target, "files": written, "installed_at": _now()}
287
- if scan_note:
288
- record["scan"] = scan_note # 危险模式扫描结果随包记账
289
- if extra:
290
- record.update(extra)
291
- installed[pack_id] = record
292
- _save_registry(reg)
293
- # 装后冒烟验证(SkillForge 证据驱动借鉴):装完重新走 skills 的解析链,
294
- # 证明该包能被加载、名字对得上、正文非空——写盘成功 ≠ 技能可用,
295
- # frontmatter 缺失/正文为空都能在这里当场暴露,而不是下次任务注入时静默丢失。
296
- smoke = ""
297
- try:
298
- with skills._LOCK:
299
- skills._user_pack_cache.clear()
300
- skills._user_dir_mtime["ts"] = 0.0
301
- skills._user_dir_mtime["ids"] = None
302
- up = next((p for p in skills.user_packs()
303
- if str(p.get("file") or "").replace("\\", "/").endswith("/" + target)), None)
304
- if up is None:
305
- smoke = "装后冒烟未找到已装包(解析失败?)"
306
- elif (up.get("name") or "").strip() != name.strip():
307
- smoke = "装后冒烟名字不符:%s ≠ %s" % (up.get("name"), name)
308
- elif not str(up.get("body") or "").strip():
309
- smoke = "装后冒烟正文为空"
310
- else:
311
- smoke = "ok:%d字" % len(str(up.get("body") or ""))
312
- except Exception as e:
313
- smoke = "装后冒烟异常: %s" % str(e)[:120]
314
- with _LOCK:
315
- reg = _load_registry()
316
- if pack_id in (reg.get("installed") or {}):
317
- reg["installed"][pack_id]["smoke"] = smoke
318
- _save_registry(reg)
319
- return {"ok": True, "id": pack_id, "name": name, "file": target,
320
- "already": already, "scan": scan_note, "smoke": smoke}, None
321
-
322
-
323
- def remove(pack_id):
324
- """卸载市场包:只删 market.json 有记录(或文件标记可自愈)且文件带本包
325
- market 标记的包。skills 内置包与无标记的用户自建包一律拒绝。
326
- 返回 None=成功,字符串=错误(沿用 skills.pack_op 的错误风格)。"""
327
- if any(p["id"] == pack_id for p in skills.BUILTIN_PACKS):
328
- return "内置经验包不可从市场卸载: %s" % pack_id
329
- with _LOCK:
330
- reg = _load_registry()
331
- installed = reg.get("installed") or {}
332
- rec = installed.get(pack_id)
333
- if not rec:
334
- marked = _scan_marked_files().get(pack_id)
335
- if marked: # 记录丢失但标记还在:按标记清理(自愈路径)
336
- rec = {"file": None, "files": sorted(marked)}
337
- else:
338
- return "该包不是市场安装的: %s" % pack_id
339
- # 只删带本包标记的文件(顶层注入件);标记被摘掉(多半已是用户内容)→
340
- # 拒绝,防误伤。market-assets/<id>/ 下的附加件由安装独占写入,按位置归属。
341
- victims = []
342
- for rel in (rec.get("files") or ([rec["file"]] if rec.get("file") else [])):
343
- p = _dest_path(pack_id, rel)
344
- if not p.is_file():
345
- continue
346
- toplevel = "/" not in str(rel).replace("\\", "/")
347
- if toplevel and not _is_market_file(p, pack_id):
348
- return "文件已不含市场标记,拒绝删除(确要删除请手工处理): %s" % p.name
349
- victims.append(p)
350
- for p in victims:
351
- try:
352
- p.unlink()
353
- except OSError as e:
354
- return "删除失败: %s" % e
355
- # 随包附加件目录(market-assets/<id>/,安装独占命名空间)整棵清掉
356
- assets = _user_pack_dir() / "market-assets" / pack_id
357
- if assets.is_dir():
358
- shutil.rmtree(assets, ignore_errors=True)
359
- installed.pop(pack_id, None)
360
- reg["installed"] = installed
361
- _save_registry(reg)
362
- return None
1
+ # -*- coding: utf-8 -*-
2
+ """插件市场(Market):经验库(skills)之上的「发现 + 一键安装」层。
3
+
4
+ 分工:skills 负责包的解析 / scope 匹配 / 注入 / 启停(运行机制),市场只负责
5
+ 「发现与安装」——把人工维护的包目录(BUILTIN_PACKS)展示出来,用户点一下就把
6
+ 包内容写进 data/skillpacks/,之后的一切(frontmatter 解析、按流程类型注入、启停)
7
+ 全部复用 skills 的既有机制,市场不自建注入通道。
8
+
9
+ 数据约定:
10
+ - 目录内容源:app/core/skillpacks/market/<pack_id>.md(与内置包同一棵目录树,
11
+ frontmatter 里带 source: market / market_id: <id> 作为安装标记);
12
+ - 安装目标:data/skillpacks/market-<pack_id>.md(skills 的用户包目录;market-
13
+ 前缀 + frontmatter 标记双保险,绝不覆盖用户自建文件,重名自动避让换名);
14
+ - 安装状态:data/market.json(原子写),记录装过哪些包、装到了哪个文件;
15
+ 记录丢失时按文件里的 market 标记扫描自愈(installed_ids)。
16
+ 卸载只认「market.json 有记录 + 文件带 market 标记」的包;skills 内置包
17
+ (app/core/skillpacks/ 下)与无标记的用户自建包一律拒绝删除。
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import re
23
+ import shutil
24
+ import threading
25
+ import time
26
+
27
+ from . import paths, skills
28
+
29
+ _LOCK = threading.RLock()
30
+
31
+ # 目录内容的源目录:与 skills 内置包同一棵目录树(app/core/skillpacks/),子目录分家
32
+ _SRC_DIR = paths.APP_DIR / "core" / "skillpacks" / "market"
33
+
34
+ # 文件 frontmatter 里的安装标记(本模块自己写的格式,正则足够)
35
+ _MARKER_RE = re.compile(r"(?m)^market_id:\s*([A-Za-z0-9_.-]+)\s*$")
36
+
37
+
38
+ def _registry_file():
39
+ """安装状态文件:每次现取 paths.DATA_DIR(测试重定向后自动跟随)。"""
40
+ return paths.DATA_DIR / "market.json"
41
+
42
+
43
+ def _user_pack_dir():
44
+ """skills 的用户包目录(安装目标),随 paths.DATA_DIR 现取。"""
45
+ return paths.DATA_DIR / "skillpacks"
46
+
47
+
48
+ def _now():
49
+ return time.strftime("%Y-%m-%d %H:%M:%S")
50
+
51
+
52
+ # ---------------------------------------------------------------- 包目录(catalog)
53
+
54
+ # 可安装的市场包目录:内容源文件在 skillpacks/market/<id>.md,import 时读入 files
55
+ # ({安装相对路径: 正文})。category 必须取 skills.LESSON_CATEGORIES 闭集枚举值。
56
+ BUILTIN_PACKS = [
57
+ {"id": "git-workflow", "name": "Git 提交与分支守则",
58
+ "desc": "提交信息格式与粒度、分支模型、force push 与回滚等危险操作红线,附提交前自检清单。",
59
+ "category": "流程规范", "scopes": ["*"], "file": "git-workflow.md"},
60
+ {"id": "code-risk-checklist", "name": "代码风险自查清单",
61
+ "desc": "提测/评审前按事故率过单:边界与异常、资源与并发、注入与泄密、跨平台与回退。",
62
+ "category": "流程规范", "scopes": ["*"], "file": "code-risk-checklist.md"},
63
+ {"id": "release-notes", "name": "版本发布说明撰写模板",
64
+ "desc": "面向用户的更新公告写法:固定六段结构、破坏性变更三要素、可复制模板与反例对照。",
65
+ "category": "文笔风格", "scopes": ["*"], "file": "release-notes.md"},
66
+ {"id": "weekly-report", "name": "周报/晨报生成器守则",
67
+ "desc": "先取材再总结:从任务与运行记录提炼晨报三段、周报四段,量化纪律与可复制模板。",
68
+ "category": "流程规范", "scopes": ["*"], "file": "weekly-report.md"},
69
+ {"id": "character-bible", "name": "角色小传与人物弧光模板",
70
+ "desc": "角色档案模板(欲望/恐惧/语言指纹)、四拍弧光规划与连载防 OOC 纪律。",
71
+ "category": "人物塑造", "scopes": ["novel", "serial_novel"], "file": "character-bible.md"},
72
+ {"id": "worldview-consistency", "name": "世界观设定一致性台账守则",
73
+ "desc": "设定台账五件套、设定变更三步流程、高频吃书场景排查与章前查章后记闭环。",
74
+ "category": "一致性", "scopes": ["novel", "serial_novel"], "file": "worldview-consistency.md"},
75
+ ]
76
+
77
+ # skills 里已内置的经验包 → 市场目录的展示信息(人工标注分类与一句话说明;
78
+ # skills 未来新增内置包时落「未分类」兜底,目录仍然可见、不可安装)
79
+ _BUILTIN_META = {
80
+ "fanqie-novel": {"category": "节奏爽点",
81
+ "desc": "算法流量池与完读追读、黄金三章整体验、题材标签匹配、更新纪律与合同要点。"},
82
+ "qimao-signing": {"category": "流程规范",
83
+ "desc": "七猫签约导向的写作规范:黄金一章、爽点纪律、期待感三源、人物红线与自检清单。"},
84
+ }
85
+
86
+
87
+ def _read_source(fname):
88
+ """读目录内容源文件(限制在本目录内,防路径逃逸);缺失返回空串。"""
89
+ try:
90
+ p = (_SRC_DIR / fname).resolve()
91
+ if _SRC_DIR.resolve() not in p.parents or not p.is_file():
92
+ return ""
93
+ return p.read_text(encoding="utf-8", errors="replace")
94
+ except Exception:
95
+ return ""
96
+
97
+
98
+ for _p in BUILTIN_PACKS:
99
+ # files: {安装相对路径: 内容}——单文件包即一个顶层 .md(装后成为 skills 用户包);
100
+ # 未来带子目录相对路径的条目会作为随包资料落到 market-assets/(见 _dest_path)
101
+ _p["files"] = {"market-%s.md" % _p["id"]: _read_source(_p["file"])}
102
+
103
+
104
+ def _dest_path(pack_id, rel):
105
+ """files 的相对路径 → 安装目标:无子目录的落 data/skillpacks/ 顶层(成为可注入
106
+ 用户包);带子目录的附加件落 data/skillpacks/market-assets/<id>/(skills 只扫
107
+ 顶层 *.md,附加件作为随包资料不参与注入)。"""
108
+ rel = str(rel).replace("\\", "/")
109
+ if "/" not in rel:
110
+ return _user_pack_dir() / rel
111
+ return _user_pack_dir() / "market-assets" / pack_id / rel
112
+
113
+
114
+ def _is_market_file(path, pack_id):
115
+ """文件是否带本包的 market 安装标记(frontmatter 的 source / market_id 两行)。"""
116
+ try:
117
+ head = path.read_text(encoding="utf-8", errors="replace")[:800]
118
+ except OSError:
119
+ return False
120
+ return "source: market" in head and ("market_id: %s" % pack_id) in head
121
+
122
+
123
+ # ---------------------------------------------------------------- 安装状态(持久化)
124
+
125
+ def _load_registry():
126
+ try:
127
+ data = json.loads(_registry_file().read_text(encoding="utf-8"))
128
+ return data if isinstance(data, dict) else {}
129
+ except Exception:
130
+ return {}
131
+
132
+
133
+ def _save_registry(data):
134
+ f = _registry_file()
135
+ f.parent.mkdir(parents=True, exist_ok=True)
136
+ tmp = f.with_suffix(".tmp")
137
+ tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
138
+ tmp.replace(f) # 原子替换(与 skills._save 同一手法)
139
+
140
+
141
+ def _scan_marked_files():
142
+ """扫 data/skillpacks/*.md 里的 market 安装标记:id → 文件名列表。
143
+ market.json 丢失(手工清理/换机)时据此自愈识别已装包。"""
144
+ found = {}
145
+ try:
146
+ for p in sorted(_user_pack_dir().glob("*.md")):
147
+ try:
148
+ head = p.read_text(encoding="utf-8", errors="replace")[:800]
149
+ except OSError:
150
+ continue
151
+ if "source: market" not in head:
152
+ continue
153
+ m = _MARKER_RE.search(head)
154
+ if m:
155
+ found.setdefault(m.group(1), []).append(p.name)
156
+ except OSError:
157
+ pass
158
+ return found
159
+
160
+
161
+ def installed_ids():
162
+ """市场已安装的包 id 集合 = market.json 记录 ∪ 文件标记扫描(自愈)。"""
163
+ with _LOCK:
164
+ ids = set((_load_registry().get("installed") or {}).keys())
165
+ ids.update(_scan_marked_files().keys())
166
+ return ids
167
+
168
+
169
+ # ---------------------------------------------------------------- 对外接口
170
+
171
+ def view():
172
+ """市场目录(给 UI/API):{catalog: [...], categories: [...]}。
173
+ 每项带 installed: bool + enabled: bool(已装包的启停态,卡片开关直接
174
+ 切换,不用再去经验库翻——对齐 ZCode 插件管理形态)。
175
+ 注意 id 双轨:市场 id(git-workflow)≠ skills 用户包 id(user-哈希),
176
+ 启停态要经安装记录的 file 路径映射到真实用户包再查。"""
177
+ installed = installed_ids()
178
+ reg = _load_registry().get("installed") or {}
179
+ ups = skills.user_packs()
180
+
181
+ def _lookup(pack_id, is_builtin):
182
+ """返回 (enabled, 可启停的真实包 id)。内置即市场 id;市场包装成的
183
+ 用户包 id 是 user-哈希,必须经安装记录的 file 路径映射。"""
184
+ if is_builtin:
185
+ return skills._pack_enabled(pack_id), pack_id
186
+ rec = reg.get(pack_id) or {}
187
+ fhint = str(rec.get("file") or "").replace("\\", "/")
188
+ for up in ups:
189
+ f = str(up.get("file") or "").replace("\\", "/")
190
+ if fhint and (f == fhint or f.endswith("/" + fhint)):
191
+ return skills._pack_enabled(up["id"]), up["id"]
192
+ return True, pack_id
193
+
194
+ items = []
195
+ for p in skills.BUILTIN_PACKS: # 已内置:已存在,不可安装
196
+ meta = _BUILTIN_META.get(p["id"]) or {}
197
+ en, pid = _lookup(p["id"], True)
198
+ items.append({"id": p["id"], "name": p["name"],
199
+ "desc": meta.get("desc") or p.get("note") or "",
200
+ "category": meta.get("category") or skills.LESSON_UNCATEGORIZED,
201
+ "scopes": list(p.get("scopes") or ["*"]),
202
+ "builtin": True, "installed": True, "installable": False,
203
+ "enabled": en, "pack_id": pid,
204
+ "chars": len(skills.pack_text(p))})
205
+ for p in BUILTIN_PACKS: # 可安装市场包
206
+ files = p.get("files") or {}
207
+ inst = p["id"] in installed
208
+ en, pid = _lookup(p["id"], False) if inst else (False, p["id"])
209
+ items.append({"id": p["id"], "name": p["name"], "desc": p["desc"],
210
+ "category": p["category"], "scopes": list(p["scopes"]),
211
+ "builtin": False, "installed": inst,
212
+ "installable": True,
213
+ "enabled": en, "pack_id": pid,
214
+ "files": sorted(files),
215
+ "chars": sum(len(v) for v in files.values())})
216
+ cats = list(skills.LESSON_CATEGORIES) # 闭集全枚举先给全(UI 下拉不缺项)
217
+ for it in items:
218
+ if it["category"] not in cats:
219
+ cats.append(it["category"])
220
+ return {"catalog": items, "categories": cats}
221
+
222
+
223
+ def install(pack_id):
224
+ """安装市场包:把 files 写进 skills 的用户包目录(data/skillpacks/),
225
+ 之后由 skills 正常解析、按 scopes 注入。返回 (结果 dict, 错误)。
226
+
227
+ 已装过(记录在且文件仍是本包的)→ 幂等返回 already=True,不覆盖用户可能的
228
+ 定制;记录在但文件丢了/被替换 → 换名重写修复,绝不覆盖无标记的用户文件;
229
+ 未知 id 与 skills 内置包 → 报错。
230
+ """
231
+ if any(p["id"] == pack_id for p in skills.BUILTIN_PACKS):
232
+ return None, "「%s」是内置经验包(已存在),无需安装" % pack_id
233
+ pack = next((p for p in BUILTIN_PACKS if p["id"] == pack_id), None)
234
+ if not pack:
235
+ return None, "市场目录中无此包: %s" % pack_id
236
+ files = pack.get("files") or {}
237
+ if not any(str(v).strip() for v in files.values()):
238
+ return None, "包内容缺失(skillpacks/market/%s 不存在或为空)" % pack.get("file")
239
+ return install_files(pack_id, pack["name"], files)
240
+
241
+
242
+ def install_files(pack_id, name, files, extra=None):
243
+ """通用安装入口:任意 {安装相对路径: 文本内容} 写进用户包目录并记账。
244
+ 内置市场包(install)与外部目录插件(market_remote)共用这一条落地通道,
245
+ 避让 / 安装标记 / market.json 记账 / 幂等纪律完全一致;extra 追加进记账记录
246
+ (如外部插件的来源与版本)。
247
+
248
+ 装前静态扫描(借鉴 NVIDIA SkillSpector):安装内容扫危险模式,风险行写进
249
+ 返回值与记账——提示不拦阻(用户仍可装),但危险必须被看见。"""
250
+ if not files or not any(str(v).strip() for v in files.values()):
251
+ return None, "包内容为空: %s" % pack_id
252
+ # 装前扫描:全文件合并扫一遍(纯内存静态规则)
253
+ scan_note = ""
254
+ try:
255
+ from . import skill_scan
256
+ scan_note = skill_scan.scan_summary("\n".join(str(v) for v in files.values()))
257
+ except Exception:
258
+ scan_note = ""
259
+ with _LOCK:
260
+ udir = _user_pack_dir()
261
+ reg = _load_registry()
262
+ installed = reg.setdefault("installed", {})
263
+ rec = installed.get(pack_id)
264
+ primary = "market-%s.md" % pack_id
265
+ target = (rec or {}).get("file") or ""
266
+ already = bool(target and _is_market_file(udir / target, pack_id))
267
+ if not already:
268
+ # 无记录 / 记录在但文件丢了或被替换:主文件选一个不踩用户文件的目标名
269
+ target = primary
270
+ n = 1
271
+ while (udir / target).exists() and not _is_market_file(udir / target, pack_id):
272
+ n += 1
273
+ target = "market-%s-%d.md" % (pack_id, n)
274
+ try:
275
+ udir.mkdir(parents=True, exist_ok=True)
276
+ written = []
277
+ for rel in sorted(files):
278
+ # 主文件落避让后的名字;带子目录的附加件按原相对路径落 assets
279
+ name_i = target if rel == primary else rel
280
+ dest = _dest_path(pack_id, name_i)
281
+ dest.parent.mkdir(parents=True, exist_ok=True)
282
+ dest.write_text(files[rel], encoding="utf-8")
283
+ written.append(name_i)
284
+ except OSError as e:
285
+ return None, "写入用户技能库失败: %s" % e
286
+ record = {"file": target, "files": written, "installed_at": _now()}
287
+ if scan_note:
288
+ record["scan"] = scan_note # 危险模式扫描结果随包记账
289
+ if extra:
290
+ record.update(extra)
291
+ installed[pack_id] = record
292
+ _save_registry(reg)
293
+ # 装后冒烟验证(SkillForge 证据驱动借鉴):装完重新走 skills 的解析链,
294
+ # 证明该包能被加载、名字对得上、正文非空——写盘成功 ≠ 技能可用,
295
+ # frontmatter 缺失/正文为空都能在这里当场暴露,而不是下次任务注入时静默丢失。
296
+ smoke = ""
297
+ try:
298
+ with skills._LOCK:
299
+ skills._user_pack_cache.clear()
300
+ skills._user_dir_mtime["ts"] = 0.0
301
+ skills._user_dir_mtime["ids"] = None
302
+ up = next((p for p in skills.user_packs()
303
+ if str(p.get("file") or "").replace("\\", "/").endswith("/" + target)), None)
304
+ if up is None:
305
+ smoke = "装后冒烟未找到已装包(解析失败?)"
306
+ elif (up.get("name") or "").strip() != name.strip():
307
+ smoke = "装后冒烟名字不符:%s ≠ %s" % (up.get("name"), name)
308
+ elif not str(up.get("body") or "").strip():
309
+ smoke = "装后冒烟正文为空"
310
+ else:
311
+ smoke = "ok:%d字" % len(str(up.get("body") or ""))
312
+ except Exception as e:
313
+ smoke = "装后冒烟异常: %s" % str(e)[:120]
314
+ with _LOCK:
315
+ reg = _load_registry()
316
+ if pack_id in (reg.get("installed") or {}):
317
+ reg["installed"][pack_id]["smoke"] = smoke
318
+ _save_registry(reg)
319
+ return {"ok": True, "id": pack_id, "name": name, "file": target,
320
+ "already": already, "scan": scan_note, "smoke": smoke}, None
321
+
322
+
323
+ def remove(pack_id):
324
+ """卸载市场包:只删 market.json 有记录(或文件标记可自愈)且文件带本包
325
+ market 标记的包。skills 内置包与无标记的用户自建包一律拒绝。
326
+ 返回 None=成功,字符串=错误(沿用 skills.pack_op 的错误风格)。"""
327
+ if any(p["id"] == pack_id for p in skills.BUILTIN_PACKS):
328
+ return "内置经验包不可从市场卸载: %s" % pack_id
329
+ with _LOCK:
330
+ reg = _load_registry()
331
+ installed = reg.get("installed") or {}
332
+ rec = installed.get(pack_id)
333
+ if not rec:
334
+ marked = _scan_marked_files().get(pack_id)
335
+ if marked: # 记录丢失但标记还在:按标记清理(自愈路径)
336
+ rec = {"file": None, "files": sorted(marked)}
337
+ else:
338
+ return "该包不是市场安装的: %s" % pack_id
339
+ # 只删带本包标记的文件(顶层注入件);标记被摘掉(多半已是用户内容)→
340
+ # 拒绝,防误伤。market-assets/<id>/ 下的附加件由安装独占写入,按位置归属。
341
+ victims = []
342
+ for rel in (rec.get("files") or ([rec["file"]] if rec.get("file") else [])):
343
+ p = _dest_path(pack_id, rel)
344
+ if not p.is_file():
345
+ continue
346
+ toplevel = "/" not in str(rel).replace("\\", "/")
347
+ if toplevel and not _is_market_file(p, pack_id):
348
+ return "文件已不含市场标记,拒绝删除(确要删除请手工处理): %s" % p.name
349
+ victims.append(p)
350
+ for p in victims:
351
+ try:
352
+ p.unlink()
353
+ except OSError as e:
354
+ return "删除失败: %s" % e
355
+ # 随包附加件目录(market-assets/<id>/,安装独占命名空间)整棵清掉
356
+ assets = _user_pack_dir() / "market-assets" / pack_id
357
+ if assets.is_dir():
358
+ shutil.rmtree(assets, ignore_errors=True)
359
+ installed.pop(pack_id, None)
360
+ reg["installed"] = installed
361
+ _save_registry(reg)
362
+ return None