codebee 0.1.17 → 0.1.19
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 +22 -0
- package/README.md +4 -3
- package/app/core/automation.py +19 -0
- package/app/core/backup.py +470 -0
- package/app/core/bookmeta.py +4 -1
- package/app/core/builtin_agent.py +33 -1
- package/app/core/cleanup.py +303 -0
- package/app/core/flows.py +1 -1
- package/app/core/jobs.py +22 -4
- package/app/core/knowledge.py +393 -0
- package/app/core/manager.py +25 -1
- package/app/core/market.py +14 -2
- package/app/core/modelhub.py +16 -0
- package/app/core/pipeline.py +62 -9
- package/app/core/planner.py +22 -4
- package/app/core/publish/manager.py +80 -3
- package/app/core/runner.py +127 -7
- package/app/core/selfupdate.py +80 -38
- package/app/core/settings.py +32 -1
- package/app/core/skill_scan.py +86 -0
- package/app/core/store.py +62 -8
- package/app/core/wxdigest.py +710 -0
- package/app/core/zentao.py +516 -50
- package/app/main.py +276 -1
- package/app/pet.py +1323 -0
- package/app/pet_bee.png +0 -0
- package/app/pet_bee_robot.png +0 -0
- package/app/pick_dialog.py +34 -2
- package/app/ui/app.js +11063 -10267
- package/app/ui/i18n.js +194 -4
- package/app/ui/index.html +148 -1
- package/app/ui/style.css +160 -2
- package/package.json +1 -1
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""知识库(Knowledge):运行产出自动整理形成的可复用知识 + 个人知识管理入口。
|
|
3
|
+
|
|
4
|
+
与经验库(skills.py)的分工边界:
|
|
5
|
+
- 教训(lessons)记「别这么做」——负面规则,来源=评审暴露的问题(verdict/issues);
|
|
6
|
+
- 知识(knowledge)记「已知是这样」——领域事实/平台规则/结论/方法论,
|
|
7
|
+
来源=run 产出材料(调研报告/文档)。两者输入源不同,提炼互不双写。
|
|
8
|
+
|
|
9
|
+
质量闸门:条目默认直接转正(approved)参与注入——用户拍板:人工把关太重,
|
|
10
|
+
提炼提示词里的「只保留有明确复用价值的」约束兜质量。status 字段保留 draft
|
|
11
|
+
枚举向后兼容,手动新建同样直接 approved;migrate_drafts_approved() 在启动时
|
|
12
|
+
把历史草稿一次性转正。
|
|
13
|
+
|
|
14
|
+
注入纪律(与教训反着来):教训是全量小注(top-8),知识默认不注入——
|
|
15
|
+
scope 命中且库里有 approved 条目才成块,按与任务目标的相关性取 top,
|
|
16
|
+
独立预算截断(KNOWLEDGE_BUDGET),绝不挤占经验包/圣经的空间。
|
|
17
|
+
选择依据在单次注入内恒定(goal 固定、id 唯一),同一任务字节稳定,
|
|
18
|
+
不碎供应商前缀缓存。
|
|
19
|
+
|
|
20
|
+
事实会过期:竞品/平台类知识每条带 as_of(事实采集日),超过 STALE_DAYS
|
|
21
|
+
注入时自动标注「可能过期」,提示模型自行核实时效。
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import json
|
|
27
|
+
import re
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from . import paths, skills
|
|
33
|
+
|
|
34
|
+
_LOCK = threading.RLock()
|
|
35
|
+
_FILE = paths.DATA_DIR / "knowledge.json"
|
|
36
|
+
|
|
37
|
+
KNOWLEDGE_BUDGET = 3000 # 注入块字符预算(独立于经验包的 MAX_INJECT_CHARS)
|
|
38
|
+
KNOWLEDGE_MAX_INJECT = 6 # 单次注入条数上限
|
|
39
|
+
STALE_DAYS = 90 # as_of 超过该天数标注「可能过期」
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _now():
|
|
43
|
+
return time.strftime("%Y-%m-%d %H:%M:%S")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _load():
|
|
47
|
+
try:
|
|
48
|
+
return json.loads(_FILE.read_text(encoding="utf-8"))
|
|
49
|
+
except Exception:
|
|
50
|
+
return {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _save(data):
|
|
54
|
+
try:
|
|
55
|
+
_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=1),
|
|
57
|
+
encoding="utf-8")
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _entry_id(scope, title):
|
|
63
|
+
"""去重指纹:scope + 归一化标题(沿用教训库的 _norm_title 标点剥离)。"""
|
|
64
|
+
h = hashlib.sha256(("%s|%s" % (scope, skills._norm_title(title)))
|
|
65
|
+
.encode("utf-8")).hexdigest()[:12]
|
|
66
|
+
return "kb-" + h
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def list_entries(scope=None, status=None, tag=None, only_enabled=False):
|
|
70
|
+
with _LOCK:
|
|
71
|
+
items = list(_load().get("entries") or [])
|
|
72
|
+
if scope:
|
|
73
|
+
items = [x for x in items if x.get("scope") in (scope, "*")]
|
|
74
|
+
if status:
|
|
75
|
+
items = [x for x in items if (x.get("status") or "draft") == status]
|
|
76
|
+
if tag:
|
|
77
|
+
items = [x for x in items if tag in (x.get("tags") or [])]
|
|
78
|
+
if only_enabled:
|
|
79
|
+
items = [x for x in items if x.get("enabled", True)]
|
|
80
|
+
items.sort(key=lambda x: (x.get("updated_at") or x.get("created_at") or ""),
|
|
81
|
+
reverse=True)
|
|
82
|
+
return items
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def upsert_entry(scope, title, body, tags=None, source="", source_file="",
|
|
86
|
+
as_of=None, status="draft"):
|
|
87
|
+
"""写入/合并一条知识。同 scope 同标题视为同一条(指纹去重):
|
|
88
|
+
- 已有条目是 approved 且新条是 draft:不覆盖正文(人工确认过的内容优先),
|
|
89
|
+
新内容记入 revisions 作为修订候选,seen+1;
|
|
90
|
+
- 其余情况(已有是 draft,或新条是 approved):正文取新。
|
|
91
|
+
返回条目;title/body 为空返回 None。"""
|
|
92
|
+
title = str(title or "").strip()[:80]
|
|
93
|
+
body = str(body or "").strip()[:1500]
|
|
94
|
+
if not title or not body:
|
|
95
|
+
return None
|
|
96
|
+
tags = [str(t).strip()[:20] for t in (tags or []) if str(t).strip()][:6]
|
|
97
|
+
status = status if status in ("draft", "approved") else "draft"
|
|
98
|
+
scope = str(scope or "*").strip() or "*"
|
|
99
|
+
kid = _entry_id(scope, title)
|
|
100
|
+
with _LOCK:
|
|
101
|
+
data = _load()
|
|
102
|
+
items = data.setdefault("entries", [])
|
|
103
|
+
for it in items:
|
|
104
|
+
if it.get("id") != kid:
|
|
105
|
+
continue
|
|
106
|
+
it["seen"] = int(it.get("seen") or 1) + 1
|
|
107
|
+
if status == "approved" or it.get("status") != "approved":
|
|
108
|
+
it["body"] = body
|
|
109
|
+
if status == "approved":
|
|
110
|
+
it["status"] = "approved"
|
|
111
|
+
else:
|
|
112
|
+
it.setdefault("revisions", []).append(
|
|
113
|
+
{"at": _now(), "body": body, "source": source})
|
|
114
|
+
it["revisions"] = it["revisions"][-5:] # 最多留 5 条修订候选
|
|
115
|
+
for tg in tags:
|
|
116
|
+
if tg not in (it.get("tags") or []):
|
|
117
|
+
it["tags"] = (it.get("tags") or []) + [tg]
|
|
118
|
+
it["tags"] = (it.get("tags") or [])[:8]
|
|
119
|
+
if as_of:
|
|
120
|
+
it["as_of"] = str(as_of)[:10]
|
|
121
|
+
if source:
|
|
122
|
+
it["source"] = source
|
|
123
|
+
if source_file:
|
|
124
|
+
it["source_file"] = source_file
|
|
125
|
+
it["updated_at"] = _now()
|
|
126
|
+
_save(data)
|
|
127
|
+
return it
|
|
128
|
+
it = {"id": kid, "scope": scope, "title": title, "body": body,
|
|
129
|
+
"tags": tags, "status": status, "enabled": True,
|
|
130
|
+
"source": source, "source_file": source_file,
|
|
131
|
+
"as_of": str(as_of or _now()[:10])[:10],
|
|
132
|
+
"revisions": [], "hits": 0, "seen": 1,
|
|
133
|
+
"created_at": _now(), "updated_at": _now(), "kind": "knowledge"}
|
|
134
|
+
items.append(it)
|
|
135
|
+
_save(data)
|
|
136
|
+
return it
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def entry_op(entry_id, op, fields=None):
|
|
140
|
+
"""新建/编辑/转正/启停/删除。返回错误或 None。
|
|
141
|
+
|
|
142
|
+
create:手动新建直接 approved(人工录入即确认);edit:改标题/正文/标签/
|
|
143
|
+
范围/账龄,不改状态——转正必须显式走 approve,保持人工闸门。
|
|
144
|
+
"""
|
|
145
|
+
if op not in ("create", "edit", "approve", "enable", "disable", "delete"):
|
|
146
|
+
return "未知操作 " + str(op)
|
|
147
|
+
if op == "create":
|
|
148
|
+
f = fields or {}
|
|
149
|
+
it = upsert_entry(f.get("scope") or "*", f.get("title") or "",
|
|
150
|
+
f.get("body") or "", tags=f.get("tags"),
|
|
151
|
+
as_of=(f.get("as_of") or "").strip() or None,
|
|
152
|
+
status="approved")
|
|
153
|
+
return None if it else "标题与正文不能为空"
|
|
154
|
+
with _LOCK:
|
|
155
|
+
data = _load()
|
|
156
|
+
items = data.get("entries") or []
|
|
157
|
+
hit = next((x for x in items if x.get("id") == entry_id), None)
|
|
158
|
+
if not hit:
|
|
159
|
+
return "知识条目不存在"
|
|
160
|
+
if op == "delete":
|
|
161
|
+
data["entries"] = [x for x in items if x.get("id") != entry_id]
|
|
162
|
+
elif op == "approve":
|
|
163
|
+
hit["status"] = "approved"
|
|
164
|
+
hit["updated_at"] = _now()
|
|
165
|
+
elif op == "edit":
|
|
166
|
+
f = fields or {}
|
|
167
|
+
if str(f.get("title") or "").strip():
|
|
168
|
+
hit["title"] = str(f["title"]).strip()[:80]
|
|
169
|
+
if str(f.get("body") or "").strip():
|
|
170
|
+
hit["body"] = str(f["body"]).strip()[:1500]
|
|
171
|
+
if "tags" in f:
|
|
172
|
+
hit["tags"] = [str(t).strip()[:20]
|
|
173
|
+
for t in (f.get("tags") or []) if str(t).strip()][:8]
|
|
174
|
+
if str(f.get("scope") or "").strip():
|
|
175
|
+
hit["scope"] = str(f["scope"]).strip()[:30]
|
|
176
|
+
if str(f.get("as_of") or "").strip():
|
|
177
|
+
hit["as_of"] = str(f["as_of"]).strip()[:10]
|
|
178
|
+
hit["updated_at"] = _now()
|
|
179
|
+
else:
|
|
180
|
+
hit["enabled"] = (op == "enable")
|
|
181
|
+
_save(data)
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _is_stale(as_of):
|
|
186
|
+
try:
|
|
187
|
+
d = time.strptime(str(as_of or "")[:10], "%Y-%m-%d")
|
|
188
|
+
return (time.mktime(time.localtime()) - time.mktime(d)) / 86400.0 > STALE_DAYS
|
|
189
|
+
except Exception:
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def block_for(task):
|
|
194
|
+
"""生成注入提示词的知识块。默认不注入:scope 命中且存在 approved 条目才成块,
|
|
195
|
+
按与任务目标/上下文的相关性取 top(复用教训库的 bigram 检索),独立预算截断。
|
|
196
|
+
无命中返回 ""——字节稳定,不碎供应商前缀缓存。"""
|
|
197
|
+
scope = (task or {}).get("type") or "*"
|
|
198
|
+
entries = [x for x in list_entries(scope, only_enabled=True)
|
|
199
|
+
if (x.get("status") or "draft") == "approved"]
|
|
200
|
+
if not entries:
|
|
201
|
+
return ""
|
|
202
|
+
probe = (skills._text_bigrams((task or {}).get("goal"))
|
|
203
|
+
| skills._text_bigrams((task or {}).get("context"))
|
|
204
|
+
| skills._text_bigrams((task or {}).get("title")))
|
|
205
|
+
if probe:
|
|
206
|
+
def rank(x):
|
|
207
|
+
grams = (skills._text_bigrams(x.get("title"))
|
|
208
|
+
| skills._text_bigrams(x.get("body"))
|
|
209
|
+
| set(x.get("tags") or []))
|
|
210
|
+
return (-len(probe & grams), x.get("id") or "")
|
|
211
|
+
entries = sorted(entries, key=rank)
|
|
212
|
+
else:
|
|
213
|
+
entries.sort(key=lambda x: x.get("id") or "")
|
|
214
|
+
entries = entries[:KNOWLEDGE_MAX_INJECT]
|
|
215
|
+
|
|
216
|
+
lines, used = [], []
|
|
217
|
+
for x in entries:
|
|
218
|
+
stale = _is_stale(x.get("as_of"))
|
|
219
|
+
mark = ("(事实截至 %s,可能过期,请自行核实时效)" % x.get("as_of") if stale
|
|
220
|
+
else ("(事实截至 %s)" % x.get("as_of") if x.get("as_of") else ""))
|
|
221
|
+
lines.append("- **%s**%s:%s" % (x["title"], mark, x["body"]))
|
|
222
|
+
used.append(x["id"])
|
|
223
|
+
text = "## 知识库(已确认的领域知识,供参考)\n\n" + "\n".join(lines)
|
|
224
|
+
if len(text) > KNOWLEDGE_BUDGET:
|
|
225
|
+
text = text[:KNOWLEDGE_BUDGET] + "\n…(已截断)"
|
|
226
|
+
if used:
|
|
227
|
+
_bump_hits(used)
|
|
228
|
+
return text
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _bump_hits(ids):
|
|
232
|
+
with _LOCK:
|
|
233
|
+
data = _load()
|
|
234
|
+
dirty = False
|
|
235
|
+
for it in (data.get("entries") or []):
|
|
236
|
+
if it.get("id") in ids:
|
|
237
|
+
it["hits"] = int(it.get("hits") or 0) + 1
|
|
238
|
+
dirty = True
|
|
239
|
+
if dirty:
|
|
240
|
+
_save(data)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ---------------------------------------------------------------- 自动整理
|
|
244
|
+
|
|
245
|
+
KNOWLEDGE_PROMPT = """你是编排系统的知识管理员。下面是一次任务的目标与它的产出材料(调研报告/文档等)。
|
|
246
|
+
请从产出中提炼**可长期复用的知识条目**:领域事实、平台规则、结论、方法论。
|
|
247
|
+
注意:只提炼事实性/结论性内容;「下次要避免什么」这类负面教训由另一个复盘流程负责,你不要写。
|
|
248
|
+
只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
|
|
249
|
+
{"entries": [{"title": "≤20 字的知识标题", "body": "具体结论(≤200 字,自含上下文,脱离本次任务也能看懂)", "tags": ["1-3 个检索标签"], "as_of": "YYYY-MM-DD(事实采集日)"}]}
|
|
250
|
+
最多 3 条,只保留有明确复用价值的;产出里没有值得沉淀的就返回空数组。
|
|
251
|
+
|
|
252
|
+
## 任务类型
|
|
253
|
+
__TYPE__
|
|
254
|
+
|
|
255
|
+
## 任务目标
|
|
256
|
+
__GOAL__
|
|
257
|
+
|
|
258
|
+
## 产出材料(节选)
|
|
259
|
+
__MATERIAL__"""
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _read_text(p):
|
|
263
|
+
"""产物读取:utf-8 严格优先,GBK 兜底(codex/pwsh 落盘编码不一)。"""
|
|
264
|
+
try:
|
|
265
|
+
return p.read_text(encoding="utf-8")
|
|
266
|
+
except Exception:
|
|
267
|
+
try:
|
|
268
|
+
return p.read_text(encoding="gbk", errors="replace")
|
|
269
|
+
except Exception:
|
|
270
|
+
return ""
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _pick_material(run):
|
|
274
|
+
"""挑本次 run 的产出材料喂给编排者:最近的 .md/.txt 文档各截 4000 字,
|
|
275
|
+
外加运行目录里的评审报告。总量封顶 10000 字。"""
|
|
276
|
+
from . import store
|
|
277
|
+
try:
|
|
278
|
+
wd, files = store.run_artifacts(run.get("id"))
|
|
279
|
+
except Exception:
|
|
280
|
+
return ""
|
|
281
|
+
chunks = []
|
|
282
|
+
try:
|
|
283
|
+
rp = paths.RUNS_DIR / (run.get("id") or "") / "report.md"
|
|
284
|
+
if rp.is_file():
|
|
285
|
+
t = _read_text(rp)
|
|
286
|
+
if t.strip():
|
|
287
|
+
chunks.append("### 评审报告(节选)\n" + t[:4000])
|
|
288
|
+
except Exception:
|
|
289
|
+
pass
|
|
290
|
+
docs = [f for f in (files or [])
|
|
291
|
+
if str(f.get("name", "")).lower().endswith((".md", ".txt"))
|
|
292
|
+
and int(f.get("size") or 0) < 500_000]
|
|
293
|
+
for f in docs[:3]:
|
|
294
|
+
t = _read_text(Path(wd) / f["name"])
|
|
295
|
+
if t.strip():
|
|
296
|
+
chunks.append("### " + f["name"] + "\n" + t[:4000])
|
|
297
|
+
return "\n\n".join(chunks)[:10000]
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def learn_from_run(run_id):
|
|
301
|
+
"""运行结束后由编排者从产出材料提炼知识条目(草稿态)。返回写入条数。
|
|
302
|
+
|
|
303
|
+
知识没有便宜的兜底路径:无编排者/无产出/非真实运行(mock)一律静默跳过,
|
|
304
|
+
宁缺毋滥——教训库的规则兜底搬到这里只会制造垃圾知识。只有正常跑完(done)
|
|
305
|
+
的运行才提炼:失败/取消的运行产物是半成品,据此沉淀的知识会污染知识库。
|
|
306
|
+
"""
|
|
307
|
+
from . import store
|
|
308
|
+
run = store.get_run(run_id)
|
|
309
|
+
if not run:
|
|
310
|
+
return 0
|
|
311
|
+
if (run.get("status") or "") != "done":
|
|
312
|
+
return 0
|
|
313
|
+
task = store.get_task(run.get("task_id")) if run.get("task_id") else None
|
|
314
|
+
if not task:
|
|
315
|
+
return 0
|
|
316
|
+
if all((s.get("agent") or "").startswith("mock") for s in (run.get("steps") or [])):
|
|
317
|
+
return 0
|
|
318
|
+
material = _pick_material(run)
|
|
319
|
+
if not material:
|
|
320
|
+
return 0
|
|
321
|
+
n = 0
|
|
322
|
+
try:
|
|
323
|
+
from . import modelhub, runner
|
|
324
|
+
orch = modelhub.resolve_orchestrator()
|
|
325
|
+
if not orch:
|
|
326
|
+
return 0
|
|
327
|
+
prov, model = orch
|
|
328
|
+
prompt = (KNOWLEDGE_PROMPT
|
|
329
|
+
.replace("__TYPE__", str(task.get("type")))
|
|
330
|
+
.replace("__GOAL__", (task.get("goal") or "")[:600])
|
|
331
|
+
.replace("__MATERIAL__", material))
|
|
332
|
+
res = modelhub.chat(prov["id"], model, prompt, max_tokens=4000, timeout=300)
|
|
333
|
+
if not res.get("ok"):
|
|
334
|
+
return 0
|
|
335
|
+
data = runner.extract_json(res.get("text") or "")
|
|
336
|
+
raw = (data or {}).get("entries") if isinstance(data, dict) else None
|
|
337
|
+
if not isinstance(raw, list):
|
|
338
|
+
return 0
|
|
339
|
+
for x in raw[:3]:
|
|
340
|
+
if not (isinstance(x, dict) and x.get("title") and x.get("body")):
|
|
341
|
+
continue
|
|
342
|
+
as_of = str(x.get("as_of") or "").strip()
|
|
343
|
+
if not re.match(r"^\d{4}-\d{2}-\d{2}$", as_of):
|
|
344
|
+
as_of = _now()[:10]
|
|
345
|
+
if upsert_entry(task.get("type") or "*", x["title"], x["body"],
|
|
346
|
+
tags=x.get("tags"), source=run_id,
|
|
347
|
+
as_of=as_of, status="approved"):
|
|
348
|
+
n += 1
|
|
349
|
+
except Exception:
|
|
350
|
+
return n
|
|
351
|
+
return n
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def learn_async(run_id):
|
|
355
|
+
"""异步提炼(不阻塞任务收尾,与教训沉淀同款语义)。"""
|
|
356
|
+
def _run():
|
|
357
|
+
try:
|
|
358
|
+
learn_from_run(run_id)
|
|
359
|
+
except Exception:
|
|
360
|
+
pass
|
|
361
|
+
threading.Thread(target=_run, name="kb-learn", daemon=True).start()
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def migrate_drafts_approved():
|
|
365
|
+
"""一次性迁移:草稿闸门退役(默认直接转正),把历史 draft 全部转正。
|
|
366
|
+
|
|
367
|
+
幂等——没有 draft 时零写入。返回转正条数。"""
|
|
368
|
+
with _LOCK:
|
|
369
|
+
data = _load()
|
|
370
|
+
items = data.get("entries") or []
|
|
371
|
+
dirty = [x for x in items if (x.get("status") or "draft") != "approved"]
|
|
372
|
+
if not dirty:
|
|
373
|
+
return 0
|
|
374
|
+
for x in dirty:
|
|
375
|
+
x["status"] = "approved"
|
|
376
|
+
x["updated_at"] = _now()
|
|
377
|
+
_save(data)
|
|
378
|
+
return len(dirty)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def view():
|
|
382
|
+
"""知识库总览(给 UI/API):条目 + 标签聚合 + 草稿数 + 账龄标记。"""
|
|
383
|
+
entries = list_entries()
|
|
384
|
+
tags, drafts = {}, 0
|
|
385
|
+
for x in entries:
|
|
386
|
+
for tg in (x.get("tags") or []):
|
|
387
|
+
tags[tg] = tags.get(tg, 0) + 1
|
|
388
|
+
if (x.get("status") or "draft") != "approved":
|
|
389
|
+
drafts += 1
|
|
390
|
+
x["stale"] = _is_stale(x.get("as_of"))
|
|
391
|
+
return {"entries": entries,
|
|
392
|
+
"tags": sorted(tags, key=lambda t: (-tags[t], t)),
|
|
393
|
+
"tag_counts": tags, "drafts": drafts, "total": len(entries)}
|
package/app/core/manager.py
CHANGED
|
@@ -859,6 +859,21 @@ def _toml_top_set(text, key, value):
|
|
|
859
859
|
return eol.join(lines).rstrip("\r\n") + eol, True
|
|
860
860
|
|
|
861
861
|
|
|
862
|
+
_TEST_HOST_SUFFIXES = (".test", ".example", ".invalid", ".localhost")
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _is_dead_endpoint(base):
|
|
866
|
+
"""明显打不通的测试/保留地址(*.test、*.example、example.com 等)。
|
|
867
|
+
这类端点落进 config.toml 后 codex 每次请求都撞死墙——2026-09-20
|
|
868
|
+
orch/p1.test 残留反复劫持全局配置的事故教训:宁可不写,不写必死端点。"""
|
|
869
|
+
host = (base or "").split("://", 1)[-1].split("/", 1)[0].split("@")[-1]
|
|
870
|
+
host = host.split(":")[0].lower().rstrip(".")
|
|
871
|
+
if not host:
|
|
872
|
+
return True
|
|
873
|
+
return (host in ("example.com", "example.org", "example.net", "localhost")
|
|
874
|
+
or host.endswith(_TEST_HOST_SUFFIXES))
|
|
875
|
+
|
|
876
|
+
|
|
862
877
|
def _sync_codex_settings(entry, model, cp):
|
|
863
878
|
"""codex 专属:把绑定供应商与模型写进 ~/.codex/config.toml
|
|
864
879
|
([model_providers.orch] 段 + 顶层 model_provider/model)。
|
|
@@ -867,6 +882,8 @@ def _sync_codex_settings(entry, model, cp):
|
|
|
867
882
|
没有 config.toml 里的 provider 段,绑定模型根本无处可用;而 model 单写
|
|
868
883
|
不写 provider 会指到 codex 自带 openai 官方端点上(401)。与编排的
|
|
869
884
|
_codex_provider_args 同构,但落 config 文件。返回错误串或 None。"""
|
|
885
|
+
if _is_dead_endpoint(cp.get("base_url")):
|
|
886
|
+
return "供应商端点 %r 是测试/保留地址,拒绝写入 config.toml" % (cp.get("base_url"),)
|
|
870
887
|
path = _config_path(entry)
|
|
871
888
|
if not path:
|
|
872
889
|
return "codex 配置路径无效"
|
|
@@ -1459,6 +1476,12 @@ def sync_runtime_config(agent):
|
|
|
1459
1476
|
break
|
|
1460
1477
|
if entry is None or not (entry.get("config") or {}).get("path"):
|
|
1461
1478
|
return
|
|
1479
|
+
if entry["id"] in ("codex-cli", "codex"):
|
|
1480
|
+
return # codex 绝不在运行防线上落盘(2026-09-20 orch 劫持事故):
|
|
1481
|
+
# 编排步骤走 runner 的 -c 一次性注入,不依赖 config.toml;
|
|
1482
|
+
# 在这里写盘会把全局 ~/.codex/config.toml 的 model_provider
|
|
1483
|
+
# 顶掉(CC Switch / 用户手动选的供应商被劫持)。交互 TUI
|
|
1484
|
+
# 场景由 launch() 自行同步,且端点防线在 _sync_codex_settings。
|
|
1462
1485
|
from . import modelhub
|
|
1463
1486
|
binding = modelhub.resolve_binding(entry["id"]) or {}
|
|
1464
1487
|
model = (binding.get("model") or "").strip()
|
|
@@ -1631,7 +1654,8 @@ def run_mgmt_command(entry, op, cancel_event=None, log_path=None):
|
|
|
1631
1654
|
# 2026-09-18 dsh 案)。
|
|
1632
1655
|
refresh_update_async(entry)
|
|
1633
1656
|
return {"ok": res["ok"], "exit_code": res["exit_code"], "command": cmd,
|
|
1634
|
-
"error": "" if res["ok"] else (res["stderr"][-800:]
|
|
1657
|
+
"error": "" if res["ok"] else (runner.clean_cli_text(res["stderr"])[-800:]
|
|
1658
|
+
or "退出码 %s" % res["exit_code"])}
|
|
1635
1659
|
|
|
1636
1660
|
|
|
1637
1661
|
def refresh_update_async(entry):
|
package/app/core/market.py
CHANGED
|
@@ -218,9 +218,19 @@ def install_files(pack_id, name, files, extra=None):
|
|
|
218
218
|
"""通用安装入口:任意 {安装相对路径: 文本内容} 写进用户包目录并记账。
|
|
219
219
|
内置市场包(install)与外部目录插件(market_remote)共用这一条落地通道,
|
|
220
220
|
避让 / 安装标记 / market.json 记账 / 幂等纪律完全一致;extra 追加进记账记录
|
|
221
|
-
(如外部插件的来源与版本)。
|
|
221
|
+
(如外部插件的来源与版本)。
|
|
222
|
+
|
|
223
|
+
装前静态扫描(借鉴 NVIDIA SkillSpector):安装内容扫危险模式,风险行写进
|
|
224
|
+
返回值与记账——提示不拦阻(用户仍可装),但危险必须被看见。"""
|
|
222
225
|
if not files or not any(str(v).strip() for v in files.values()):
|
|
223
226
|
return None, "包内容为空: %s" % pack_id
|
|
227
|
+
# 装前扫描:全文件合并扫一遍(纯内存静态规则)
|
|
228
|
+
scan_note = ""
|
|
229
|
+
try:
|
|
230
|
+
from . import skill_scan
|
|
231
|
+
scan_note = skill_scan.scan_summary("\n".join(str(v) for v in files.values()))
|
|
232
|
+
except Exception:
|
|
233
|
+
scan_note = ""
|
|
224
234
|
with _LOCK:
|
|
225
235
|
udir = _user_pack_dir()
|
|
226
236
|
reg = _load_registry()
|
|
@@ -249,12 +259,14 @@ def install_files(pack_id, name, files, extra=None):
|
|
|
249
259
|
except OSError as e:
|
|
250
260
|
return None, "写入用户技能库失败: %s" % e
|
|
251
261
|
record = {"file": target, "files": written, "installed_at": _now()}
|
|
262
|
+
if scan_note:
|
|
263
|
+
record["scan"] = scan_note # 危险模式扫描结果随包记账
|
|
252
264
|
if extra:
|
|
253
265
|
record.update(extra)
|
|
254
266
|
installed[pack_id] = record
|
|
255
267
|
_save_registry(reg)
|
|
256
268
|
return {"ok": True, "id": pack_id, "name": name, "file": target,
|
|
257
|
-
"already": already}, None
|
|
269
|
+
"already": already, "scan": scan_note}, None
|
|
258
270
|
|
|
259
271
|
|
|
260
272
|
def remove(pack_id):
|
package/app/core/modelhub.py
CHANGED
|
@@ -835,6 +835,12 @@ def _is_codex_target(target):
|
|
|
835
835
|
return (target or "").strip().lower() in ("codex-cli", "codex", "codex-code")
|
|
836
836
|
|
|
837
837
|
|
|
838
|
+
def _is_aider_target(target):
|
|
839
|
+
"""aider 的绑定键与 orch.kind 同名(catalog id=aider);兜底前缀匹配防变体。"""
|
|
840
|
+
t = (target or "").strip().lower()
|
|
841
|
+
return t == "aider" or t.startswith("aider-")
|
|
842
|
+
|
|
843
|
+
|
|
838
844
|
def note_codex_wire_dead(provider_id, minutes=30):
|
|
839
845
|
"""codex 撞上 wire 不兼容的供应商 → 供应商级冷却(自动绕开的记账位)。
|
|
840
846
|
|
|
@@ -2037,8 +2043,18 @@ def _chain_entry_env(prov, model, target="", endpoint=None, key="", key_id="",
|
|
|
2037
2043
|
"ANTHROPIC_AUTH_TOKEN": use_key}
|
|
2038
2044
|
if model:
|
|
2039
2045
|
out["env"]["ANTHROPIC_MODEL"] = model
|
|
2046
|
+
if _is_aider_target(target):
|
|
2047
|
+
# aider(litellm)不认 AUTH_TOKEN,只认 ANTHROPIC_API_KEY——缺了它
|
|
2048
|
+
# 直接报 LLM Provider NOT provided(2026-09-20 连载评审 aider 全灭根因)。
|
|
2049
|
+
# claude 绝不注入:x-api-key 与 Bearer 两种鉴权头网关挑食,不能混。
|
|
2050
|
+
out["env"]["ANTHROPIC_API_KEY"] = use_key
|
|
2040
2051
|
else:
|
|
2041
2052
|
out["env"] = {"ORCH_API_KEY": use_key}
|
|
2053
|
+
if _is_aider_target(target):
|
|
2054
|
+
# litellm 的 openai 通道读这对 env;base 语义与 codex chat wire 一致
|
|
2055
|
+
# (调用方拼 /chat/completions)
|
|
2056
|
+
out["env"]["OPENAI_API_KEY"] = use_key
|
|
2057
|
+
out["env"]["OPENAI_API_BASE"] = base
|
|
2042
2058
|
wire_api = endpoint[2] if endpoint else prov.get("wire_api", "responses")
|
|
2043
2059
|
out["codex_provider"] = {
|
|
2044
2060
|
"name": "orch", "base_url": base,
|