codebee 0.1.103 → 0.1.105
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.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -21
- package/README.md +5 -3
- package/app/core/acceptance.py +100 -100
- package/app/core/acp_client.py +457 -457
- package/app/core/aiflavor.py +197 -197
- package/app/core/analytics.py +38 -38
- package/app/core/artifacts.py +148 -148
- package/app/core/assets.py +110 -110
- package/app/core/attachments.py +623 -623
- package/app/core/audit.py +30 -30
- package/app/core/backend_profiles.py +188 -188
- package/app/core/backup.py +470 -470
- package/app/core/beekeeper.py +183 -183
- package/app/core/benchstore.py +102 -102
- package/app/core/bookmeta.py +1036 -1036
- package/app/core/bookmeta_catalog.py +180 -180
- package/app/core/branching.py +137 -137
- package/app/core/capability.py +144 -144
- package/app/core/chaptersafe.py +32 -32
- package/app/core/checkpoints.py +48 -5
- package/app/core/cleanup.py +303 -303
- package/app/core/command_auth.py +302 -302
- package/app/core/compaction.py +201 -201
- package/app/core/contracts.py +255 -255
- package/app/core/covergen.py +356 -356
- package/app/core/defectretro.py +43 -43
- package/app/core/diagnostics.py +114 -114
- package/app/core/dispatch_log.py +128 -128
- package/app/core/env_scrub.py +85 -85
- package/app/core/errorlog.py +137 -137
- package/app/core/eval_matrix.py +91 -91
- package/app/core/evaluation.py +174 -174
- package/app/core/failure_notify.py +290 -290
- package/app/core/flow_graph.py +33 -33
- package/app/core/goal_service.py +167 -167
- package/app/core/health.py +389 -389
- package/app/core/history.py +38 -38
- package/app/core/hooks.py +162 -162
- package/app/core/knowledge_pipeline.py +86 -86
- package/app/core/manager.py +2386 -2385
- package/app/core/mocks.py +64 -64
- package/app/core/notify.py +201 -201
- package/app/core/novel_quality.py +285 -181
- package/app/core/openapi.py +246 -217
- package/app/core/operations.py +228 -228
- package/app/core/paihang.py +145 -145
- package/app/core/pipeline.py +81 -11
- package/app/core/planner.py +7 -0
- package/app/core/portguard.py +111 -111
- package/app/core/presence.py +77 -77
- package/app/core/preview.py +293 -293
- package/app/core/project_memory.py +252 -206
- package/app/core/prompt_guard.py +78 -78
- package/app/core/publish/__init__.py +7 -7
- package/app/core/publish/auto.py +659 -659
- package/app/core/publish/chaprows.py +138 -138
- package/app/core/publish/flows-fanqie-calibrated.json +442 -442
- package/app/core/publish/manager.py +1185 -1185
- package/app/core/publish/qimao.py +193 -193
- package/app/core/publish/volumes.py +108 -108
- package/app/core/publish/ws.py +139 -139
- package/app/core/quality_gate.py +201 -201
- package/app/core/redact.py +38 -38
- package/app/core/registry.py +35 -0
- package/app/core/repeat_guard.py +129 -129
- package/app/core/retrieval.py +137 -137
- package/app/core/review_requests.py +119 -0
- package/app/core/runner.py +5 -1
- package/app/core/sandbox_runtime.py +95 -95
- package/app/core/selfupdate.py +384 -384
- package/app/core/session_log.py +161 -161
- package/app/core/sessions.py +312 -312
- package/app/core/settings.py +215 -215
- package/app/core/settings_schema.py +282 -282
- package/app/core/share_page.py +114 -114
- package/app/core/skill_scan.py +139 -139
- package/app/core/skillpacks/market/character-bible.md +66 -66
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
- package/app/core/skillpacks/market/git-workflow.md +57 -57
- package/app/core/skillpacks/market/release-notes.md +72 -72
- package/app/core/skillpacks/market/weekly-report.md +71 -71
- package/app/core/skillpacks/market/worldview-consistency.md +70 -70
- package/app/core/step_runner.py +132 -132
- package/app/core/story_tracking.py +275 -275
- package/app/core/task_compile.py +199 -199
- package/app/core/telemetry.py +292 -292
- package/app/core/tlsctx.py +64 -64
- package/app/core/token_meter.py +147 -147
- package/app/core/tool_outputs.py +107 -107
- package/app/core/tracing.py +207 -207
- package/app/core/upstream.py +41 -41
- package/app/core/usage.py +1038 -1038
- package/app/core/volumes.py +389 -389
- package/app/core/webhooks.py +89 -89
- package/app/core/wxdigest.py +865 -865
- package/app/core/zentao.py +2692 -2692
- package/app/main.py +43 -0
- package/app/mcp_server.py +244 -244
- package/app/pick_dialog.py +110 -110
- package/app/ui/app.js +21 -2
- package/app/ui/artdiff.js +74 -74
- package/app/ui/hive3d.js +54 -31
- package/app/ui/i18n.js +32 -1
- package/app/ui/icons/bee.svg +79 -79
- package/app/ui/index.html +1 -0
- package/app/ui/manifest.json +16 -16
- package/app/ui/qrcode.js +2297 -2297
- package/bin/tutti.js +147 -147
- package/package.json +1 -1
- package/tools/wx_reader.py +261 -261
- package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
- package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
- package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
- package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
- package/app/.playwright-cli/run-detail-after.png +0 -0
- package/app/output/_createbook_body.json +0 -1
- package/app/output/_probe_fq_click.py +0 -50
- package/app/output/_probe_fq_form.py +0 -42
- package/app/output/_probe_fq_modal.py +0 -55
- package/app/output/_probe_fq_points.py +0 -71
- package/app/output/_probe_fq_submit.py +0 -34
- package/app/output/_probe_fq_watch.py +0 -62
- package/app/output/workbench-clean-preview.jpg +0 -0
- package/app/output/workbench-reference-render.png +0 -0
- package/app/output/workbench-shell.png +0 -0
|
@@ -1,275 +1,275 @@
|
|
|
1
|
-
"""Structured, deterministic long-form fiction tracking.
|
|
2
|
-
|
|
3
|
-
The JSON state is the source of truth. Markdown files under ``derived`` are
|
|
4
|
-
regenerated from that state and are checked for drift before a run consumes
|
|
5
|
-
them. This keeps long serial projects resumable without requiring a vector DB.
|
|
6
|
-
"""
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
import hashlib
|
|
10
|
-
import json
|
|
11
|
-
import re
|
|
12
|
-
import time
|
|
13
|
-
from pathlib import Path
|
|
14
|
-
|
|
15
|
-
_MAX_CHAPTERS = 5000
|
|
16
|
-
_STATE_REL = Path(".codebee") / "story_tracking.json"
|
|
17
|
-
_DERIVED = Path(".codebee") / "derived"
|
|
18
|
-
_CHAPTERS = Path(".codebee") / "chapters"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def _now():
|
|
22
|
-
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def _root(workdir):
|
|
26
|
-
root = Path(workdir).expanduser().resolve()
|
|
27
|
-
if not root.is_dir():
|
|
28
|
-
raise ValueError("工作目录不存在")
|
|
29
|
-
return root
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def _state_path(root):
|
|
33
|
-
return root / _STATE_REL
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def _digest(text):
|
|
37
|
-
return hashlib.sha256(str(text).encode("utf-8")).hexdigest()
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def _norm_lf(text):
|
|
41
|
-
"""换行归一到 LF。章稿经 _read_chapter 保真读回时带 CRLF(Windows 上
|
|
42
|
-
_write_chapter 文本模式落盘所致),不归一会让同一份稿在不同路径下
|
|
43
|
-
摘要不一致。"""
|
|
44
|
-
return str(text).replace("\r\n", "\n").replace("\r", "\n")
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def _empty(task_id, title, premise=""):
|
|
48
|
-
return {"version": 1, "task_id": str(task_id), "title": str(title or "")[:120],
|
|
49
|
-
"premise": str(premise or "")[:2000], "state_revision": 0,
|
|
50
|
-
"current_chapter": 0, "facts": [], "characters": [], "timeline": [],
|
|
51
|
-
"foreshadowing": [], "next_promises": [], "author_truth": [],
|
|
52
|
-
"reader_known": [], "chapters": [], "derived_digests": {},
|
|
53
|
-
"created_at": _now(), "updated_at": _now()}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def _save(root, state):
|
|
57
|
-
path = _state_path(root)
|
|
58
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
-
tmp = path.with_suffix(".tmp")
|
|
60
|
-
tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
61
|
-
tmp.replace(path)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def _load(root):
|
|
65
|
-
path = _state_path(root)
|
|
66
|
-
try:
|
|
67
|
-
data = json.loads(path.read_text(encoding="utf-8"))
|
|
68
|
-
except FileNotFoundError:
|
|
69
|
-
return None
|
|
70
|
-
except (OSError, ValueError) as exc:
|
|
71
|
-
raise ValueError("故事追踪状态不可读: %s" % exc)
|
|
72
|
-
if not isinstance(data, dict) or data.get("version") != 1:
|
|
73
|
-
raise ValueError("不支持的故事追踪状态版本")
|
|
74
|
-
for name in ("facts", "characters", "timeline", "foreshadowing",
|
|
75
|
-
"next_promises", "author_truth", "reader_known", "chapters"):
|
|
76
|
-
if not isinstance(data.get(name), list):
|
|
77
|
-
raise ValueError("故事追踪状态字段 %s 必须是列表" % name)
|
|
78
|
-
if not isinstance(data.get("derived_digests"), dict):
|
|
79
|
-
raise ValueError("故事追踪状态字段 derived_digests 必须是对象")
|
|
80
|
-
if any(not isinstance(row, dict) for row in data.get("chapters") or []):
|
|
81
|
-
raise ValueError("故事追踪状态 chapters 含无效记录")
|
|
82
|
-
return data
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def init(workdir, task_id, title, premise=""):
|
|
86
|
-
root = _root(workdir)
|
|
87
|
-
state = _load(root)
|
|
88
|
-
if state is None:
|
|
89
|
-
state = _empty(task_id, title, premise)
|
|
90
|
-
_save(root, state)
|
|
91
|
-
_render(root, state)
|
|
92
|
-
return state
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def load(workdir):
|
|
96
|
-
return _load(_root(workdir))
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def _clean_lines(values, limit=100):
|
|
100
|
-
result = []
|
|
101
|
-
for value in values if isinstance(values, list) else []:
|
|
102
|
-
text = str(value or "").strip()[:1000]
|
|
103
|
-
if text and text not in result:
|
|
104
|
-
result.append(text)
|
|
105
|
-
if len(result) >= limit:
|
|
106
|
-
break
|
|
107
|
-
return result
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def _clean_foreshadowing(values):
|
|
111
|
-
result = []
|
|
112
|
-
for raw in values if isinstance(values, list) else []:
|
|
113
|
-
if not isinstance(raw, dict):
|
|
114
|
-
continue
|
|
115
|
-
item = {"id": str(raw.get("id") or "").strip()[:40],
|
|
116
|
-
"text": str(raw.get("text") or "").strip()[:1000],
|
|
117
|
-
"status": str(raw.get("status") or "active").strip()[:30],
|
|
118
|
-
"planted_chapter": raw.get("planted_chapter"),
|
|
119
|
-
"payoff_chapter": raw.get("payoff_chapter")}
|
|
120
|
-
if item["id"] and item["text"]:
|
|
121
|
-
result.append(item)
|
|
122
|
-
return result[-500:]
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def commit_chapter(workdir, chapter_no, outline, prose, *, facts=None, characters=None,
|
|
126
|
-
timeline=None, foreshadowing=None, next_promises=None,
|
|
127
|
-
author_truth=None, reader_known=None):
|
|
128
|
-
root = _root(workdir)
|
|
129
|
-
try:
|
|
130
|
-
number = int(chapter_no)
|
|
131
|
-
except (TypeError, ValueError):
|
|
132
|
-
raise ValueError("章节号无效")
|
|
133
|
-
if not 1 <= number <= _MAX_CHAPTERS:
|
|
134
|
-
raise ValueError("章节号必须在 1-%d 之间" % _MAX_CHAPTERS)
|
|
135
|
-
outline = str(outline or "").strip()
|
|
136
|
-
prose = _norm_lf(str(prose or "").strip())
|
|
137
|
-
if not outline:
|
|
138
|
-
raise ValueError("提交章节前必须有细纲")
|
|
139
|
-
if not prose:
|
|
140
|
-
raise ValueError("正文不能为空")
|
|
141
|
-
state = _load(root)
|
|
142
|
-
if state is None:
|
|
143
|
-
state = _empty("", root.name)
|
|
144
|
-
# A shared workdir can be used by continuation tasks. The structured
|
|
145
|
-
# tracking file is book-level state, so accepting a newer task id here
|
|
146
|
-
# would make the next run look like a foreign-state drift. Keep the
|
|
147
|
-
# original owner and append chapters to the existing book timeline.
|
|
148
|
-
record = {"chapter": number, "outline": outline[:12000], "prose_digest": _digest(prose),
|
|
149
|
-
"word_count": len(prose), "committed_at": _now(),
|
|
150
|
-
"facts": _clean_lines(facts), "characters": _clean_lines(characters),
|
|
151
|
-
"timeline": _clean_lines(timeline), "foreshadowing": _clean_foreshadowing(foreshadowing),
|
|
152
|
-
"next_promises": _clean_lines(next_promises, 20),
|
|
153
|
-
"author_truth": _clean_lines(author_truth, 100), "reader_known": _clean_lines(reader_known, 100)}
|
|
154
|
-
existing = next((x for x in state["chapters"] if x.get("chapter") == number), None)
|
|
155
|
-
if existing and existing.get("prose_digest") == record["prose_digest"] and existing.get("outline") == outline:
|
|
156
|
-
return state
|
|
157
|
-
if existing:
|
|
158
|
-
state["chapters"] = [x for x in state["chapters"] if x.get("chapter") != number]
|
|
159
|
-
state["chapters"].append(record)
|
|
160
|
-
state["chapters"].sort(key=lambda x: int(x.get("chapter") or 0))
|
|
161
|
-
state["current_chapter"] = max(int(state.get("current_chapter") or 0), number)
|
|
162
|
-
state["facts"] = _clean_lines([x for c in state["chapters"] for x in c.get("facts", [])], 500)
|
|
163
|
-
state["characters"] = _clean_lines([x for c in state["chapters"] for x in c.get("characters", [])], 300)
|
|
164
|
-
state["timeline"] = _clean_lines([x for c in state["chapters"] for x in c.get("timeline", [])], 500)
|
|
165
|
-
state["foreshadowing"] = _clean_foreshadowing([x for c in state["chapters"] for x in c.get("foreshadowing", [])])
|
|
166
|
-
state["next_promises"] = record["next_promises"]
|
|
167
|
-
state["author_truth"] = record["author_truth"]
|
|
168
|
-
state["reader_known"] = record["reader_known"]
|
|
169
|
-
state["state_revision"] = int(state.get("state_revision") or 0) + 1
|
|
170
|
-
state["updated_at"] = _now()
|
|
171
|
-
chapter_dir = _CHAPTERS / ("%04d" % number)
|
|
172
|
-
(root / chapter_dir).mkdir(parents=True, exist_ok=True)
|
|
173
|
-
# 字节落盘钉死 LF:文本模式写会把已含 CRLF 的章稿二次翻译成 \r\r\n,
|
|
174
|
-
# 磁盘稿与状态摘要永久错位,续写批开场漂移闸必拦(2026-10-03 实案)
|
|
175
|
-
(root / chapter_dir / "outline.md").write_bytes((outline + "\n").encode("utf-8"))
|
|
176
|
-
(root / chapter_dir / "prose.md").write_bytes((prose + "\n").encode("utf-8"))
|
|
177
|
-
_save(root, state)
|
|
178
|
-
_render(root, state)
|
|
179
|
-
return state
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
def _render(root, state):
|
|
183
|
-
derived = root / _DERIVED
|
|
184
|
-
derived.mkdir(parents=True, exist_ok=True)
|
|
185
|
-
lines = ["# 故事上下文", "", "- 书名:%s" % state.get("title", ""),
|
|
186
|
-
"- 当前章:第%s章" % state.get("current_chapter", 0),
|
|
187
|
-
"- 状态修订:%s" % state.get("state_revision", 0), ""]
|
|
188
|
-
if state.get("premise"):
|
|
189
|
-
lines += ["## 核心 premise", "", state["premise"], ""]
|
|
190
|
-
lines += ["## 长期事实", ""] + ["- " + x for x in state.get("facts", [])] + [""]
|
|
191
|
-
lines += ["## 活跃伏笔", ""]
|
|
192
|
-
for item in state.get("foreshadowing", []):
|
|
193
|
-
lines.append("- %s|%s|%s" % (item.get("id"), item.get("text"), item.get("status")))
|
|
194
|
-
lines += ["", "## 下一章承诺", ""] + ["- " + x for x in state.get("next_promises", [])] + [""]
|
|
195
|
-
lines += ["## 作者真相", ""] + ["- " + x for x in state.get("author_truth", [])] + [""]
|
|
196
|
-
lines += ["## 读者已知", ""] + ["- " + x for x in state.get("reader_known", [])] + [""]
|
|
197
|
-
context = "\n".join(lines).rstrip() + "\n"
|
|
198
|
-
(derived / "context.md").write_text(context, encoding="utf-8")
|
|
199
|
-
(derived / "foreshadowing.md").write_text("# 伏笔\n\n" + "\n".join(
|
|
200
|
-
"- %s|%s|%s" % (x.get("id"), x.get("text"), x.get("status"))
|
|
201
|
-
for x in state.get("foreshadowing", [])) + "\n", encoding="utf-8")
|
|
202
|
-
(derived / "timeline.md").write_text("# 时间线\n\n" + "\n".join(
|
|
203
|
-
"- " + x for x in state.get("timeline", [])) + "\n", encoding="utf-8")
|
|
204
|
-
state["derived_digests"] = {}
|
|
205
|
-
for name in ("context.md", "foreshadowing.md", "timeline.md"):
|
|
206
|
-
state["derived_digests"][name] = _digest((derived / name).read_text(encoding="utf-8"))
|
|
207
|
-
_save(root, state)
|
|
208
|
-
try:
|
|
209
|
-
from . import retrieval
|
|
210
|
-
for name in ("context.md", "foreshadowing.md", "timeline.md"):
|
|
211
|
-
path = derived / name
|
|
212
|
-
retrieval.upsert(str(path), path.read_text(encoding="utf-8"),
|
|
213
|
-
{"task_id": state.get("task_id"), "kind": "story_tracking"})
|
|
214
|
-
except Exception:
|
|
215
|
-
pass
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
def _prose_matches(path, digest):
|
|
219
|
-
"""章稿摘要比对,兼容三种历史形态。
|
|
220
|
-
|
|
221
|
-
- 新写:文件纯 LF,状态摘要按 LF 文本计算;
|
|
222
|
-
- 旧写(文本模式二次翻译):磁盘 \\r\\r\\n,状态摘要按 CRLF 原文计算——
|
|
223
|
-
CRCRLF 是写出路径独有的字节形态,先按字节愈合回 CRLF 再比对即可认定
|
|
224
|
-
同源,不必要求用户手工修复历史状态(2026-10-03 续写批漂移闸误杀实案);
|
|
225
|
-
- 旧写(未二次翻译):磁盘普通 CRLF,摘要按 CRLF 原文计算。
|
|
226
|
-
注意愈合必须在字节层做:read_text 的通用换行翻译会把 \\r\\r\\n 折成 \\n\\n,
|
|
227
|
-
事后无从区分「真空行」与「二次翻译」,就再也对不上了。
|
|
228
|
-
"""
|
|
229
|
-
if not digest:
|
|
230
|
-
return False
|
|
231
|
-
try:
|
|
232
|
-
data = path.read_bytes()
|
|
233
|
-
except OSError:
|
|
234
|
-
return False
|
|
235
|
-
candidates = set()
|
|
236
|
-
for raw in (data, data.replace(b"\r\r\n", b"\r\n")):
|
|
237
|
-
text = raw.decode("utf-8", "replace")
|
|
238
|
-
candidates.add(_digest(text.strip()))
|
|
239
|
-
candidates.add(_digest(_norm_lf(text).strip()))
|
|
240
|
-
return digest in candidates
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
def check(workdir):
|
|
244
|
-
root = _root(workdir)
|
|
245
|
-
state = _load(root)
|
|
246
|
-
if not state:
|
|
247
|
-
return {"ok": False, "errors": ["缺少故事追踪状态"]}
|
|
248
|
-
errors = []
|
|
249
|
-
for name, digest in (state.get("derived_digests") or {}).items():
|
|
250
|
-
path = root / _DERIVED / name
|
|
251
|
-
if not path.is_file():
|
|
252
|
-
errors.append("缺少派生文件 %s" % name)
|
|
253
|
-
continue
|
|
254
|
-
actual = _digest(path.read_text(encoding="utf-8"))
|
|
255
|
-
if actual != digest:
|
|
256
|
-
errors.append("派生文件已被手工修改: %s" % name)
|
|
257
|
-
for chapter in state.get("chapters") or []:
|
|
258
|
-
number = int(chapter.get("chapter") or 0)
|
|
259
|
-
outline = root / _CHAPTERS / ("%04d" % number) / "outline.md"
|
|
260
|
-
prose = root / _CHAPTERS / ("%04d" % number) / "prose.md"
|
|
261
|
-
if not outline.is_file() or not prose.is_file():
|
|
262
|
-
errors.append("章节文件缺失: %04d" % number)
|
|
263
|
-
elif outline.read_text(encoding="utf-8").strip() != str(chapter.get("outline") or "").strip():
|
|
264
|
-
# A continuation run may legitimately replace the shared outline
|
|
265
|
-
# with a book-level outline containing additional future chapters.
|
|
266
|
-
# Only reject a mismatch when the persisted chapter's own outline
|
|
267
|
-
# is no longer represented in the current file.
|
|
268
|
-
persisted = str(chapter.get("outline") or "").strip()
|
|
269
|
-
current = outline.read_text(encoding="utf-8").strip()
|
|
270
|
-
if persisted and persisted not in current:
|
|
271
|
-
errors.append("章节细纲不一致: %04d" % number)
|
|
272
|
-
elif not _prose_matches(prose, chapter.get("prose_digest")):
|
|
273
|
-
errors.append("章节正文不一致: %04d" % number)
|
|
274
|
-
return {"ok": not errors, "errors": errors, "state_revision": state.get("state_revision", 0),
|
|
275
|
-
"current_chapter": state.get("current_chapter", 0)}
|
|
1
|
+
"""Structured, deterministic long-form fiction tracking.
|
|
2
|
+
|
|
3
|
+
The JSON state is the source of truth. Markdown files under ``derived`` are
|
|
4
|
+
regenerated from that state and are checked for drift before a run consumes
|
|
5
|
+
them. This keeps long serial projects resumable without requiring a vector DB.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
_MAX_CHAPTERS = 5000
|
|
16
|
+
_STATE_REL = Path(".codebee") / "story_tracking.json"
|
|
17
|
+
_DERIVED = Path(".codebee") / "derived"
|
|
18
|
+
_CHAPTERS = Path(".codebee") / "chapters"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _now():
|
|
22
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _root(workdir):
|
|
26
|
+
root = Path(workdir).expanduser().resolve()
|
|
27
|
+
if not root.is_dir():
|
|
28
|
+
raise ValueError("工作目录不存在")
|
|
29
|
+
return root
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _state_path(root):
|
|
33
|
+
return root / _STATE_REL
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _digest(text):
|
|
37
|
+
return hashlib.sha256(str(text).encode("utf-8")).hexdigest()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _norm_lf(text):
|
|
41
|
+
"""换行归一到 LF。章稿经 _read_chapter 保真读回时带 CRLF(Windows 上
|
|
42
|
+
_write_chapter 文本模式落盘所致),不归一会让同一份稿在不同路径下
|
|
43
|
+
摘要不一致。"""
|
|
44
|
+
return str(text).replace("\r\n", "\n").replace("\r", "\n")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _empty(task_id, title, premise=""):
|
|
48
|
+
return {"version": 1, "task_id": str(task_id), "title": str(title or "")[:120],
|
|
49
|
+
"premise": str(premise or "")[:2000], "state_revision": 0,
|
|
50
|
+
"current_chapter": 0, "facts": [], "characters": [], "timeline": [],
|
|
51
|
+
"foreshadowing": [], "next_promises": [], "author_truth": [],
|
|
52
|
+
"reader_known": [], "chapters": [], "derived_digests": {},
|
|
53
|
+
"created_at": _now(), "updated_at": _now()}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _save(root, state):
|
|
57
|
+
path = _state_path(root)
|
|
58
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
tmp = path.with_suffix(".tmp")
|
|
60
|
+
tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
61
|
+
tmp.replace(path)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _load(root):
|
|
65
|
+
path = _state_path(root)
|
|
66
|
+
try:
|
|
67
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
68
|
+
except FileNotFoundError:
|
|
69
|
+
return None
|
|
70
|
+
except (OSError, ValueError) as exc:
|
|
71
|
+
raise ValueError("故事追踪状态不可读: %s" % exc)
|
|
72
|
+
if not isinstance(data, dict) or data.get("version") != 1:
|
|
73
|
+
raise ValueError("不支持的故事追踪状态版本")
|
|
74
|
+
for name in ("facts", "characters", "timeline", "foreshadowing",
|
|
75
|
+
"next_promises", "author_truth", "reader_known", "chapters"):
|
|
76
|
+
if not isinstance(data.get(name), list):
|
|
77
|
+
raise ValueError("故事追踪状态字段 %s 必须是列表" % name)
|
|
78
|
+
if not isinstance(data.get("derived_digests"), dict):
|
|
79
|
+
raise ValueError("故事追踪状态字段 derived_digests 必须是对象")
|
|
80
|
+
if any(not isinstance(row, dict) for row in data.get("chapters") or []):
|
|
81
|
+
raise ValueError("故事追踪状态 chapters 含无效记录")
|
|
82
|
+
return data
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def init(workdir, task_id, title, premise=""):
|
|
86
|
+
root = _root(workdir)
|
|
87
|
+
state = _load(root)
|
|
88
|
+
if state is None:
|
|
89
|
+
state = _empty(task_id, title, premise)
|
|
90
|
+
_save(root, state)
|
|
91
|
+
_render(root, state)
|
|
92
|
+
return state
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def load(workdir):
|
|
96
|
+
return _load(_root(workdir))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _clean_lines(values, limit=100):
|
|
100
|
+
result = []
|
|
101
|
+
for value in values if isinstance(values, list) else []:
|
|
102
|
+
text = str(value or "").strip()[:1000]
|
|
103
|
+
if text and text not in result:
|
|
104
|
+
result.append(text)
|
|
105
|
+
if len(result) >= limit:
|
|
106
|
+
break
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _clean_foreshadowing(values):
|
|
111
|
+
result = []
|
|
112
|
+
for raw in values if isinstance(values, list) else []:
|
|
113
|
+
if not isinstance(raw, dict):
|
|
114
|
+
continue
|
|
115
|
+
item = {"id": str(raw.get("id") or "").strip()[:40],
|
|
116
|
+
"text": str(raw.get("text") or "").strip()[:1000],
|
|
117
|
+
"status": str(raw.get("status") or "active").strip()[:30],
|
|
118
|
+
"planted_chapter": raw.get("planted_chapter"),
|
|
119
|
+
"payoff_chapter": raw.get("payoff_chapter")}
|
|
120
|
+
if item["id"] and item["text"]:
|
|
121
|
+
result.append(item)
|
|
122
|
+
return result[-500:]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def commit_chapter(workdir, chapter_no, outline, prose, *, facts=None, characters=None,
|
|
126
|
+
timeline=None, foreshadowing=None, next_promises=None,
|
|
127
|
+
author_truth=None, reader_known=None):
|
|
128
|
+
root = _root(workdir)
|
|
129
|
+
try:
|
|
130
|
+
number = int(chapter_no)
|
|
131
|
+
except (TypeError, ValueError):
|
|
132
|
+
raise ValueError("章节号无效")
|
|
133
|
+
if not 1 <= number <= _MAX_CHAPTERS:
|
|
134
|
+
raise ValueError("章节号必须在 1-%d 之间" % _MAX_CHAPTERS)
|
|
135
|
+
outline = str(outline or "").strip()
|
|
136
|
+
prose = _norm_lf(str(prose or "").strip())
|
|
137
|
+
if not outline:
|
|
138
|
+
raise ValueError("提交章节前必须有细纲")
|
|
139
|
+
if not prose:
|
|
140
|
+
raise ValueError("正文不能为空")
|
|
141
|
+
state = _load(root)
|
|
142
|
+
if state is None:
|
|
143
|
+
state = _empty("", root.name)
|
|
144
|
+
# A shared workdir can be used by continuation tasks. The structured
|
|
145
|
+
# tracking file is book-level state, so accepting a newer task id here
|
|
146
|
+
# would make the next run look like a foreign-state drift. Keep the
|
|
147
|
+
# original owner and append chapters to the existing book timeline.
|
|
148
|
+
record = {"chapter": number, "outline": outline[:12000], "prose_digest": _digest(prose),
|
|
149
|
+
"word_count": len(prose), "committed_at": _now(),
|
|
150
|
+
"facts": _clean_lines(facts), "characters": _clean_lines(characters),
|
|
151
|
+
"timeline": _clean_lines(timeline), "foreshadowing": _clean_foreshadowing(foreshadowing),
|
|
152
|
+
"next_promises": _clean_lines(next_promises, 20),
|
|
153
|
+
"author_truth": _clean_lines(author_truth, 100), "reader_known": _clean_lines(reader_known, 100)}
|
|
154
|
+
existing = next((x for x in state["chapters"] if x.get("chapter") == number), None)
|
|
155
|
+
if existing and existing.get("prose_digest") == record["prose_digest"] and existing.get("outline") == outline:
|
|
156
|
+
return state
|
|
157
|
+
if existing:
|
|
158
|
+
state["chapters"] = [x for x in state["chapters"] if x.get("chapter") != number]
|
|
159
|
+
state["chapters"].append(record)
|
|
160
|
+
state["chapters"].sort(key=lambda x: int(x.get("chapter") or 0))
|
|
161
|
+
state["current_chapter"] = max(int(state.get("current_chapter") or 0), number)
|
|
162
|
+
state["facts"] = _clean_lines([x for c in state["chapters"] for x in c.get("facts", [])], 500)
|
|
163
|
+
state["characters"] = _clean_lines([x for c in state["chapters"] for x in c.get("characters", [])], 300)
|
|
164
|
+
state["timeline"] = _clean_lines([x for c in state["chapters"] for x in c.get("timeline", [])], 500)
|
|
165
|
+
state["foreshadowing"] = _clean_foreshadowing([x for c in state["chapters"] for x in c.get("foreshadowing", [])])
|
|
166
|
+
state["next_promises"] = record["next_promises"]
|
|
167
|
+
state["author_truth"] = record["author_truth"]
|
|
168
|
+
state["reader_known"] = record["reader_known"]
|
|
169
|
+
state["state_revision"] = int(state.get("state_revision") or 0) + 1
|
|
170
|
+
state["updated_at"] = _now()
|
|
171
|
+
chapter_dir = _CHAPTERS / ("%04d" % number)
|
|
172
|
+
(root / chapter_dir).mkdir(parents=True, exist_ok=True)
|
|
173
|
+
# 字节落盘钉死 LF:文本模式写会把已含 CRLF 的章稿二次翻译成 \r\r\n,
|
|
174
|
+
# 磁盘稿与状态摘要永久错位,续写批开场漂移闸必拦(2026-10-03 实案)
|
|
175
|
+
(root / chapter_dir / "outline.md").write_bytes((outline + "\n").encode("utf-8"))
|
|
176
|
+
(root / chapter_dir / "prose.md").write_bytes((prose + "\n").encode("utf-8"))
|
|
177
|
+
_save(root, state)
|
|
178
|
+
_render(root, state)
|
|
179
|
+
return state
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _render(root, state):
|
|
183
|
+
derived = root / _DERIVED
|
|
184
|
+
derived.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
lines = ["# 故事上下文", "", "- 书名:%s" % state.get("title", ""),
|
|
186
|
+
"- 当前章:第%s章" % state.get("current_chapter", 0),
|
|
187
|
+
"- 状态修订:%s" % state.get("state_revision", 0), ""]
|
|
188
|
+
if state.get("premise"):
|
|
189
|
+
lines += ["## 核心 premise", "", state["premise"], ""]
|
|
190
|
+
lines += ["## 长期事实", ""] + ["- " + x for x in state.get("facts", [])] + [""]
|
|
191
|
+
lines += ["## 活跃伏笔", ""]
|
|
192
|
+
for item in state.get("foreshadowing", []):
|
|
193
|
+
lines.append("- %s|%s|%s" % (item.get("id"), item.get("text"), item.get("status")))
|
|
194
|
+
lines += ["", "## 下一章承诺", ""] + ["- " + x for x in state.get("next_promises", [])] + [""]
|
|
195
|
+
lines += ["## 作者真相", ""] + ["- " + x for x in state.get("author_truth", [])] + [""]
|
|
196
|
+
lines += ["## 读者已知", ""] + ["- " + x for x in state.get("reader_known", [])] + [""]
|
|
197
|
+
context = "\n".join(lines).rstrip() + "\n"
|
|
198
|
+
(derived / "context.md").write_text(context, encoding="utf-8")
|
|
199
|
+
(derived / "foreshadowing.md").write_text("# 伏笔\n\n" + "\n".join(
|
|
200
|
+
"- %s|%s|%s" % (x.get("id"), x.get("text"), x.get("status"))
|
|
201
|
+
for x in state.get("foreshadowing", [])) + "\n", encoding="utf-8")
|
|
202
|
+
(derived / "timeline.md").write_text("# 时间线\n\n" + "\n".join(
|
|
203
|
+
"- " + x for x in state.get("timeline", [])) + "\n", encoding="utf-8")
|
|
204
|
+
state["derived_digests"] = {}
|
|
205
|
+
for name in ("context.md", "foreshadowing.md", "timeline.md"):
|
|
206
|
+
state["derived_digests"][name] = _digest((derived / name).read_text(encoding="utf-8"))
|
|
207
|
+
_save(root, state)
|
|
208
|
+
try:
|
|
209
|
+
from . import retrieval
|
|
210
|
+
for name in ("context.md", "foreshadowing.md", "timeline.md"):
|
|
211
|
+
path = derived / name
|
|
212
|
+
retrieval.upsert(str(path), path.read_text(encoding="utf-8"),
|
|
213
|
+
{"task_id": state.get("task_id"), "kind": "story_tracking"})
|
|
214
|
+
except Exception:
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _prose_matches(path, digest):
|
|
219
|
+
"""章稿摘要比对,兼容三种历史形态。
|
|
220
|
+
|
|
221
|
+
- 新写:文件纯 LF,状态摘要按 LF 文本计算;
|
|
222
|
+
- 旧写(文本模式二次翻译):磁盘 \\r\\r\\n,状态摘要按 CRLF 原文计算——
|
|
223
|
+
CRCRLF 是写出路径独有的字节形态,先按字节愈合回 CRLF 再比对即可认定
|
|
224
|
+
同源,不必要求用户手工修复历史状态(2026-10-03 续写批漂移闸误杀实案);
|
|
225
|
+
- 旧写(未二次翻译):磁盘普通 CRLF,摘要按 CRLF 原文计算。
|
|
226
|
+
注意愈合必须在字节层做:read_text 的通用换行翻译会把 \\r\\r\\n 折成 \\n\\n,
|
|
227
|
+
事后无从区分「真空行」与「二次翻译」,就再也对不上了。
|
|
228
|
+
"""
|
|
229
|
+
if not digest:
|
|
230
|
+
return False
|
|
231
|
+
try:
|
|
232
|
+
data = path.read_bytes()
|
|
233
|
+
except OSError:
|
|
234
|
+
return False
|
|
235
|
+
candidates = set()
|
|
236
|
+
for raw in (data, data.replace(b"\r\r\n", b"\r\n")):
|
|
237
|
+
text = raw.decode("utf-8", "replace")
|
|
238
|
+
candidates.add(_digest(text.strip()))
|
|
239
|
+
candidates.add(_digest(_norm_lf(text).strip()))
|
|
240
|
+
return digest in candidates
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def check(workdir):
|
|
244
|
+
root = _root(workdir)
|
|
245
|
+
state = _load(root)
|
|
246
|
+
if not state:
|
|
247
|
+
return {"ok": False, "errors": ["缺少故事追踪状态"]}
|
|
248
|
+
errors = []
|
|
249
|
+
for name, digest in (state.get("derived_digests") or {}).items():
|
|
250
|
+
path = root / _DERIVED / name
|
|
251
|
+
if not path.is_file():
|
|
252
|
+
errors.append("缺少派生文件 %s" % name)
|
|
253
|
+
continue
|
|
254
|
+
actual = _digest(path.read_text(encoding="utf-8"))
|
|
255
|
+
if actual != digest:
|
|
256
|
+
errors.append("派生文件已被手工修改: %s" % name)
|
|
257
|
+
for chapter in state.get("chapters") or []:
|
|
258
|
+
number = int(chapter.get("chapter") or 0)
|
|
259
|
+
outline = root / _CHAPTERS / ("%04d" % number) / "outline.md"
|
|
260
|
+
prose = root / _CHAPTERS / ("%04d" % number) / "prose.md"
|
|
261
|
+
if not outline.is_file() or not prose.is_file():
|
|
262
|
+
errors.append("章节文件缺失: %04d" % number)
|
|
263
|
+
elif outline.read_text(encoding="utf-8").strip() != str(chapter.get("outline") or "").strip():
|
|
264
|
+
# A continuation run may legitimately replace the shared outline
|
|
265
|
+
# with a book-level outline containing additional future chapters.
|
|
266
|
+
# Only reject a mismatch when the persisted chapter's own outline
|
|
267
|
+
# is no longer represented in the current file.
|
|
268
|
+
persisted = str(chapter.get("outline") or "").strip()
|
|
269
|
+
current = outline.read_text(encoding="utf-8").strip()
|
|
270
|
+
if persisted and persisted not in current:
|
|
271
|
+
errors.append("章节细纲不一致: %04d" % number)
|
|
272
|
+
elif not _prose_matches(prose, chapter.get("prose_digest")):
|
|
273
|
+
errors.append("章节正文不一致: %04d" % number)
|
|
274
|
+
return {"ok": not errors, "errors": errors, "state_revision": state.get("state_revision", 0),
|
|
275
|
+
"current_chapter": state.get("current_chapter", 0)}
|