codebee 0.1.73 → 0.1.76
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 +23 -0
- package/README.md +5 -4
- package/app/core/analytics.py +38 -0
- package/app/core/bookmeta.py +115 -14
- package/app/core/contracts.py +255 -0
- package/app/core/error_codes.py +9 -0
- package/app/core/health.py +16 -1
- package/app/core/jobs.py +72 -0
- package/app/core/manager.py +38 -14
- package/app/core/modelhub.py +104 -9
- package/app/core/openapi.py +154 -0
- package/app/core/pipeline.py +210 -18
- package/app/core/presence.py +77 -0
- package/app/core/publish/auto.py +11 -0
- package/app/core/retrieval.py +137 -0
- package/app/core/runner.py +101 -5
- package/app/core/store.py +72 -0
- package/app/core/story_tracking.py +275 -0
- package/app/main.py +240 -20
- package/app/mcp_server.py +75 -6
- package/app/ui/app.js +237 -43
- package/app/ui/i18n.js +34 -5
- package/app/ui/index.html +9 -0
- package/app/ui/style.css +32 -6
- package/package.json +1 -1
package/app/core/runner.py
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"""
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
|
+
import glob
|
|
15
16
|
import json
|
|
16
17
|
import os
|
|
17
18
|
import re
|
|
@@ -68,10 +69,50 @@ def find_git_bash():
|
|
|
68
69
|
return None
|
|
69
70
|
|
|
70
71
|
|
|
72
|
+
_CLI_EXTS = (".exe", ".cmd", ".bat")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _fallback_probe_cli(name):
|
|
76
|
+
"""which() 落空时按常见安装位兜底找 CLI(Windows only),返回全路径或 None。
|
|
77
|
+
|
|
78
|
+
服务进程的 PATH 是启动那一刻的快照:CLI 在服务起跑之后才安装/迁移(如
|
|
79
|
+
2026-09-30 claude 转 winget 原生安装)时,快照里没有新目录,which 解析
|
|
80
|
+
不到、裸名直传 Popen 直接 FileNotFoundError,直到重启服务才自愈。这里
|
|
81
|
+
只探测磁盘已知落点,不改 PATH、不缓存(装没装是低频事件,探几次不亏)。
|
|
82
|
+
"""
|
|
83
|
+
if os.name != "nt":
|
|
84
|
+
return None
|
|
85
|
+
name = str(name)
|
|
86
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", name):
|
|
87
|
+
return None # 带斜杠/参数形态的不是裸命令名,交给原路径逻辑
|
|
88
|
+
local = os.environ.get("LOCALAPPDATA") or ""
|
|
89
|
+
home = os.path.expanduser("~")
|
|
90
|
+
appdata = os.environ.get("APPDATA") or os.path.join(home, "AppData", "Roaming")
|
|
91
|
+
flat_dirs = (
|
|
92
|
+
os.path.join(local, "Microsoft", "WinGet", "Links"),
|
|
93
|
+
os.path.join(home, ".local", "bin"),
|
|
94
|
+
os.path.join(appdata, "npm"),
|
|
95
|
+
)
|
|
96
|
+
for d in flat_dirs:
|
|
97
|
+
for ext in _CLI_EXTS:
|
|
98
|
+
p = os.path.join(d, name + ext)
|
|
99
|
+
if os.path.isfile(p):
|
|
100
|
+
return p
|
|
101
|
+
# winget 便携包:Packages\<厂商.Id_*>\name.EXE(目录名带来源哈希,glob 一层)
|
|
102
|
+
pkgs = os.path.join(local, "Microsoft", "WinGet", "Packages")
|
|
103
|
+
try:
|
|
104
|
+
for p in glob.glob(os.path.join(pkgs, "*", name + ".*")):
|
|
105
|
+
if p.lower().endswith(_CLI_EXTS) and os.path.isfile(p):
|
|
106
|
+
return p
|
|
107
|
+
except OSError:
|
|
108
|
+
pass
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
71
112
|
def resolve_command(command):
|
|
72
113
|
"""命令名 → 可直接 spawn 的 argv 前缀(.cmd/.bat 垫片必须经 cmd /c)。"""
|
|
73
|
-
path = shutil.which(command) or command
|
|
74
|
-
low = path.lower()
|
|
114
|
+
path = shutil.which(command) or _fallback_probe_cli(command) or command
|
|
115
|
+
low = str(path).lower()
|
|
75
116
|
if low.endswith(".cmd") or low.endswith(".bat"):
|
|
76
117
|
return ["cmd", "/c", path]
|
|
77
118
|
return [path]
|
|
@@ -719,6 +760,18 @@ def run_process(argv=None, shell_cmd=None, stdin_text=None, cwd=None, env=None,
|
|
|
719
760
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
720
761
|
creationflags=CREATE_NO_WINDOW,
|
|
721
762
|
start_new_session=(os.name != "nt")) # POSIX 需独立进程组供 killpg 杀树;Windows 忽略该参数
|
|
763
|
+
except FileNotFoundError as e:
|
|
764
|
+
# filename=None 时 %r 只有一句「系统找不到指定的文件」,无从排查
|
|
765
|
+
# (2026-09-30 claude winget 迁移实案:服务 PATH 快照过旧)。
|
|
766
|
+
# e.filename 在场(如 POSIX cwd 缺失)时点名真实缺失路径,不猜 argv[0]。
|
|
767
|
+
tgt = str(e.filename) if getattr(e, "filename", None) \
|
|
768
|
+
else (str(argv[0]) if argv else "")
|
|
769
|
+
return {"ok": False, "exit_code": None, "stdout": "",
|
|
770
|
+
"stderr": ("启动失败:找不到可执行文件「%s」。CLI 未安装或已卸载;"
|
|
771
|
+
"若刚安装,重启 CodeBee 服务让 PATH 生效即可。"
|
|
772
|
+
% tgt),
|
|
773
|
+
"duration": 0.0, "cancelled": False, "timed_out": False,
|
|
774
|
+
"stalled": False, "deadline_exceeded": False, "abort_marker": None}
|
|
722
775
|
except Exception as e:
|
|
723
776
|
return {"ok": False, "exit_code": None, "stdout": "",
|
|
724
777
|
"stderr": "启动失败: %r" % e, "duration": 0.0,
|
|
@@ -1503,9 +1556,20 @@ def _build_call(agent, kind, sid, readonly, model, prompt, images=None, workdir=
|
|
|
1503
1556
|
and argv.count(prompt) == 1:
|
|
1504
1557
|
pf = _prompt_to_file(prompt, workdir)
|
|
1505
1558
|
if pf:
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1559
|
+
if kind == "aider":
|
|
1560
|
+
# aider 非交互态没有「读文件工具」语义:--message 指挥它读文件
|
|
1561
|
+
# 会被模型判定为提示注入直接拒绝(2026-10-03 不周山守村人全局
|
|
1562
|
+
# 评审实案:glm-5.3 十秒交白卷 → 全局有效评审恒 1 名,门禁
|
|
1563
|
+
# 「≥2 名」结构性不可能通过)。aider 的 --read 把文件作为只读
|
|
1564
|
+
# 会话文件由 CLI 自己读入,语义与交互式 aider file.md 一致。
|
|
1565
|
+
argv[argv.index(prompt)] = (
|
|
1566
|
+
"本次完整任务指令已作为只读文件加入会话,"
|
|
1567
|
+
"请直接依据该文件内容执行任务。")
|
|
1568
|
+
argv += ["--read", pf]
|
|
1569
|
+
else:
|
|
1570
|
+
argv[argv.index(prompt)] = (
|
|
1571
|
+
"[系统] 本次完整指令因命令行长度限制已写入文件:%s\n"
|
|
1572
|
+
"请先用读文件工具完整读取该文件,然后把文件内容当作你的任务指令执行。" % pf)
|
|
1509
1573
|
# Keep the original payload on stdin as a compatibility path for
|
|
1510
1574
|
# simple generic adapters and test shims that read stdin. CLI tools
|
|
1511
1575
|
# that intentionally use argv still receive the bounded file hint.
|
|
@@ -1578,6 +1642,30 @@ def _invalid_model(model):
|
|
|
1578
1642
|
return str(model or "").strip().lower() in _INVALID_MODEL_NAMES
|
|
1579
1643
|
|
|
1580
1644
|
|
|
1645
|
+
# opencode 启动横幅形如「> build · glm-5.3」(ANSI 清洗后一行、圆点分隔);
|
|
1646
|
+
# 真错误(配额/配置/网络)CLI 都会另打印文本,不会是纯横幅形态
|
|
1647
|
+
_OC_BANNER_LINE_RE = re.compile(r"^\s*>?\s*[^\s·•]+\s*[·•]\s*\S+\s*$")
|
|
1648
|
+
|
|
1649
|
+
|
|
1650
|
+
def _opencode_fast_banner_death(res):
|
|
1651
|
+
"""opencode 启动期秒退签名:退出码 1、总时长很短、输出清洗 ANSI 后只剩
|
|
1652
|
+
启动横幅、没有任何错误文本。真实失败不走这个形态;该形态只值得原地
|
|
1653
|
+
立即重试一次(配置写读竞态/端口抢占类瞬时抖动,2026-09-30 实案:
|
|
1654
|
+
并行评审爆发期连续三发「退出码 1;stderr/stdout: > build · glm-5.3」,
|
|
1655
|
+
数分钟后同进程自愈)。"""
|
|
1656
|
+
if res.get("ok") or res.get("exit_code") != 1:
|
|
1657
|
+
return False
|
|
1658
|
+
try:
|
|
1659
|
+
if float(res.get("duration") or 0) > 15.0:
|
|
1660
|
+
return False
|
|
1661
|
+
except (TypeError, ValueError):
|
|
1662
|
+
return False
|
|
1663
|
+
blob = clean_cli_text((res.get("stderr") or "") + "\n"
|
|
1664
|
+
+ (res.get("stdout") or ""))
|
|
1665
|
+
lines = [ln.strip() for ln in blob.splitlines() if ln.strip()]
|
|
1666
|
+
return bool(lines) and all(_OC_BANNER_LINE_RE.match(ln) for ln in lines)
|
|
1667
|
+
|
|
1668
|
+
|
|
1581
1669
|
def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
1582
1670
|
timeout=DEFAULT_TIMEOUT, cancel_event=None, log_path=None, resume=None,
|
|
1583
1671
|
images=None, require_tools=False, deadline=None):
|
|
@@ -1801,6 +1889,14 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1801
1889
|
"provider_id": att.get("provider_id") or "",
|
|
1802
1890
|
"provider": att.get("provider") or {}}
|
|
1803
1891
|
if not res["ok"]:
|
|
1892
|
+
if (kind == "opencode" and attempt == 0
|
|
1893
|
+
and _opencode_fast_banner_death(res)
|
|
1894
|
+
and not (cancel_event is not None
|
|
1895
|
+
and cancel_event.is_set())):
|
|
1896
|
+
_log_note(log_path,
|
|
1897
|
+
"opencode 启动即退(仅横幅无错误文本)——按瞬时抖动 2s 后原地重试一次")
|
|
1898
|
+
time.sleep(2.0)
|
|
1899
|
+
continue
|
|
1804
1900
|
# stderr 与 stdout 都要进错误串:codex 把 "Reading prompt from
|
|
1805
1901
|
# stdin..." 打在 stderr,真正的配额/限流错误全在 stdout 的 JSONL
|
|
1806
1902
|
# 里——只取其一会让 _quota_error/_transient_error 判空。
|
package/app/core/store.py
CHANGED
|
@@ -195,6 +195,18 @@ def create_task(payload):
|
|
|
195
195
|
# 参数修订号(借鉴 WorkDSH 契约纪律 expectedRevision):写接口据此做过期
|
|
196
196
|
# 写入检测。老任务无此键按 1 处理;不带 expected_rev 的调用不受影响。
|
|
197
197
|
task["rev"] = 1
|
|
198
|
+
# Durable task contract: acceptance criteria and approval requirement are
|
|
199
|
+
# persisted outside the task JSON so evidence/audit history survives task
|
|
200
|
+
# parameter edits and can be consumed by API/MCP clients independently.
|
|
201
|
+
raw_criteria = payload.get("acceptance_criteria")
|
|
202
|
+
if raw_criteria not in (None, "") and not isinstance(raw_criteria, list):
|
|
203
|
+
raise ValueError("acceptance_criteria 必须是列表")
|
|
204
|
+
task["acceptance_criteria"] = [str(x).strip()[:500] for x in
|
|
205
|
+
(raw_criteria or []) if str(x).strip()][:40]
|
|
206
|
+
approval_required = payload.get("approval_required", False)
|
|
207
|
+
if not isinstance(approval_required, bool):
|
|
208
|
+
raise ValueError("approval_required 必须是布尔值")
|
|
209
|
+
task["approval_required"] = approval_required
|
|
198
210
|
if flow["engine"] == "direct":
|
|
199
211
|
task["direct_provider_id"] = _text(payload.get("direct_provider_id"),
|
|
200
212
|
"direct_provider_id")[:80]
|
|
@@ -388,6 +400,24 @@ def create_task(payload):
|
|
|
388
400
|
with LOCK:
|
|
389
401
|
_TASKS[task["id"]] = task
|
|
390
402
|
_save_json(paths.TASKS_DIR / (task["id"] + ".json"), task)
|
|
403
|
+
try:
|
|
404
|
+
from . import contracts
|
|
405
|
+
contracts.create(task["id"], {
|
|
406
|
+
"acceptance_criteria": task.get("acceptance_criteria"),
|
|
407
|
+
"approval_required": task.get("approval_required"),
|
|
408
|
+
"actor": "system",
|
|
409
|
+
})
|
|
410
|
+
except Exception:
|
|
411
|
+
# Contracts are an evidence enrichment layer; legacy task creation
|
|
412
|
+
# must remain available if a damaged optional contract file is found.
|
|
413
|
+
pass
|
|
414
|
+
if task.get("serial"):
|
|
415
|
+
try:
|
|
416
|
+
from . import story_tracking
|
|
417
|
+
story_tracking.init(task["workdir"], task["id"], task["title"],
|
|
418
|
+
premise=task.get("context") or task.get("goal") or "")
|
|
419
|
+
except Exception:
|
|
420
|
+
pass
|
|
391
421
|
return task
|
|
392
422
|
|
|
393
423
|
|
|
@@ -535,6 +565,30 @@ def serial_chain_ids(task_id):
|
|
|
535
565
|
return order
|
|
536
566
|
|
|
537
567
|
|
|
568
|
+
def chain_book_meta(task, platform):
|
|
569
|
+
"""任务在某平台的作品信息「面板视图」:根优先返回首个有状态的条目。
|
|
570
|
+
|
|
571
|
+
与 inherited_book_meta 同为根优先,但不筛 done——生成可从链上任意任务
|
|
572
|
+
触发、落点在根任务,running/failed 也要沿链可见,否则续写批次触发生成
|
|
573
|
+
后面板永远空转。running/done 优先于 failed(旧失败不遮新结果)。"""
|
|
574
|
+
task = task or {}
|
|
575
|
+
platform = str(platform)
|
|
576
|
+
own = ((task.get("book_meta") or {}).get(platform)) or {}
|
|
577
|
+
first_failed = None
|
|
578
|
+
for tid in serial_chain_ids(task.get("id") or ""):
|
|
579
|
+
t = task if tid == str(task.get("id")) else get_task(tid)
|
|
580
|
+
if not t:
|
|
581
|
+
continue
|
|
582
|
+
e = ((t.get("book_meta") or {}).get(platform)) or {}
|
|
583
|
+
if not (isinstance(e, dict) and e.get("status")):
|
|
584
|
+
continue
|
|
585
|
+
if e.get("status") in ("running", "done"):
|
|
586
|
+
return e
|
|
587
|
+
if first_failed is None:
|
|
588
|
+
first_failed = e
|
|
589
|
+
return first_failed or own
|
|
590
|
+
|
|
591
|
+
|
|
538
592
|
def inherited_book_meta(task, platform):
|
|
539
593
|
"""任务在某平台的作品信息:连载链上「生成完成」的条目整链共用(都是用第一个)。
|
|
540
594
|
|
|
@@ -1050,6 +1104,22 @@ def update_run(run_id, expected_status=None, **fields):
|
|
|
1050
1104
|
# Acceptance reporting is evidence enrichment; it must not
|
|
1051
1105
|
# prevent the primary run from reaching a terminal state.
|
|
1052
1106
|
pass
|
|
1107
|
+
try:
|
|
1108
|
+
from . import contracts
|
|
1109
|
+
for case in (run.get("acceptance") or {}).get("cases") or []:
|
|
1110
|
+
contracts.add_evidence(
|
|
1111
|
+
run.get("task_id") or "",
|
|
1112
|
+
{"criterion": "acceptance:%s" % case.get("case"),
|
|
1113
|
+
"status": "passed" if case.get("status") == "passed" else case.get("status"),
|
|
1114
|
+
"summary": case.get("reason") or "acceptance matrix",
|
|
1115
|
+
"source": "acceptance"}, actor="system")
|
|
1116
|
+
contracts.record_acceptance_cases(
|
|
1117
|
+
run.get("task_id") or "",
|
|
1118
|
+
(run.get("acceptance") or {}).get("cases") or [],
|
|
1119
|
+
actor="system")
|
|
1120
|
+
contracts.complete(run.get("task_id") or "", run)
|
|
1121
|
+
except Exception:
|
|
1122
|
+
pass
|
|
1053
1123
|
_save_json(paths.RUNS_DIR / run_id / "run.json", run)
|
|
1054
1124
|
if st in TERMINAL_STATUSES and prev_status not in TERMINAL_STATUSES:
|
|
1055
1125
|
# 事后对账:只有「非终态 → 终态」这一次翻转才记账,重复写终态
|
|
@@ -1720,6 +1790,8 @@ def continue_task(task_id, chapters=None):
|
|
|
1720
1790
|
"difficulty": task.get("difficulty") or "auto",
|
|
1721
1791
|
"implementer": task.get("implementer") or "",
|
|
1722
1792
|
"critics": task.get("critics") or [],
|
|
1793
|
+
"acceptance_criteria": task.get("acceptance_criteria") or [],
|
|
1794
|
+
"approval_required": bool(task.get("approval_required")),
|
|
1723
1795
|
"manuscript": task.get("manuscript") or "manuscript.md",
|
|
1724
1796
|
"threshold": task.get("threshold") or 7.0,
|
|
1725
1797
|
"serial": {"chapters": batch,
|
|
@@ -0,0 +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)}
|