@thincoder/core 0.10.10 → 0.10.11

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 (70) hide show
  1. package/CHANGELOG.md +28 -2
  2. package/agent/completion.mjs +4 -1
  3. package/agent/family-tools.mjs +3 -3
  4. package/agent/helpers.mjs +13 -11
  5. package/agent/light-round.mjs +1 -4
  6. package/agent/record-results.mjs +6 -1
  7. package/agent/setup-reminders.mjs +1 -1
  8. package/agent/unbooked-pen.mjs +62 -0
  9. package/agent-tools/advisor.mjs +15 -31
  10. package/agent-tools/audit-block.mjs +16 -62
  11. package/agent-tools/batch-gate.mjs +10 -18
  12. package/agent-tools/context.mjs +1 -1
  13. package/agent-tools/design-token.mjs +9 -2
  14. package/agent-tools/digest-budget.mjs +2 -0
  15. package/agent-tools/doc-check.mjs +3 -14
  16. package/agent-tools/manifest.mjs +3 -4
  17. package/agent-tools/review-facts.mjs +35 -81
  18. package/agent-tools/settings.mjs +65 -5
  19. package/agent-tools/spawn-gates.mjs +68 -31
  20. package/agent-tools/subagent-async.mjs +6 -3
  21. package/agent-tools/subagent-spawn.mjs +42 -61
  22. package/agent-tools/subagent.mjs +15 -15
  23. package/browser/launch.mjs +14 -3
  24. package/browser/queue.mjs +31 -3
  25. package/browser/session.mjs +4 -0
  26. package/config-defaults.mjs +8 -0
  27. package/config-presets.mjs +1 -1
  28. package/config.mjs +60 -11
  29. package/doc-check/anchors.mjs +10 -9
  30. package/doc-check/index.mjs +27 -23
  31. package/doc-check/paragraph.mjs +119 -0
  32. package/fts-text.mjs +4 -1
  33. package/ledger-cmd.mjs +17 -4
  34. package/ledger-db.mjs +14 -8
  35. package/ledger.mjs +1 -1
  36. package/manifest-discovery.mjs +19 -10
  37. package/manifest.mjs +17 -5
  38. package/model-context.mjs +98 -0
  39. package/model-specs.mjs +110 -37
  40. package/package.json +2 -2
  41. package/permission.mjs +3 -1
  42. package/prompts/advisor-design.md +15 -0
  43. package/prompts/common.md +1 -0
  44. package/prompts/discipline-engineering.md +27 -6
  45. package/prompts/discipline-normal.md +7 -7
  46. package/prompts/persona-eng-designer.md +16 -4
  47. package/prompts/persona-engineering.md +16 -27
  48. package/provider/anthropic.mjs +127 -4
  49. package/provider/core.mjs +16 -6
  50. package/provider/google.mjs +8 -8
  51. package/provider/list-models.mjs +37 -5
  52. package/provider/normalize.mjs +2 -2
  53. package/provider/responses.mjs +7 -7
  54. package/provider/sse.mjs +6 -5
  55. package/proxy-transport.mjs +5 -3
  56. package/proxy.mjs +2 -1
  57. package/session-guard.mjs +7 -1
  58. package/session-lifecycle.mjs +23 -8
  59. package/session-segments.mjs +1 -0
  60. package/session-slot-claims.mjs +4 -0
  61. package/session-slot-write.mjs +6 -0
  62. package/session-slots-manifest.mjs +4 -0
  63. package/token-ttl.mjs +10 -0
  64. package/tool-docs/advisor.md +24 -5
  65. package/tool-docs/doc_check.md +23 -16
  66. package/tool-docs/ledger.md +2 -2
  67. package/tool-docs/subagent.md +41 -16
  68. package/tools/web-deepseek.mjs +26 -11
  69. package/doc-check/version.mjs +0 -84
  70. package/doc-check/width.mjs +0 -87
@@ -2,7 +2,7 @@
2
2
  * doc-check/index.mjs — 文档纪律机检 · 引擎入口(两条判据 + 报面行)。
3
3
  *
4
4
  * 判据两条(起步 · 闭集——口径单源 = `docs/core/design/DOC-DISCIPLINE.md` §3.20 条目 S):
5
- * ① 锚(档缺 / 节缺)——实现 = `./anchors.mjs`;② 行宽——口径 = §3.18 条目 Q,实现 = `./width.mjs`。
5
+ * ① 锚(档缺 / 节缺)——实现 = `./anchors.mjs`;② 段判据(段太长 ∥ 行太长)——口径 = §3.18 条目 Q,实现 = `./paragraph.mjs`。
6
6
  * 判据参数 = **工具内置常量** `DOC_CHECK_BUILTINS`:不读任何项目的声明面,因而没有「缺参数 ⇒ 拒跑」这一层,
7
7
  * 也没有常量校验面——改判据 = 改代码、走评审。
8
8
  * 判定范围 = **调用方传入的文件清单**(判定范围 = 解析范围 = 传入的那几档;无目录遍历、无全域兜底):
@@ -15,14 +15,15 @@
15
15
  */
16
16
  import { statSync } from "node:fs"
17
17
  import { relative, resolve } from "node:path"
18
- import { checkDocWidths } from "./width.mjs"
18
+ import { checkParagraphs } from "./paragraph.mjs"
19
19
  import { checkAnchors } from "./anchors.mjs"
20
20
 
21
- /** 判据参数(内置常量 · 冻结):阈值 300 字符;两项空列表 = 区带类 / 豁免类默认值且恒空
22
- * ——豁免谓词(表行 / 围栏 / 可执行行 / 引文)住代码,没有「登记后放过」的写入口(空列表 ≠ 可登记豁免)。 */
21
+ /** 判据参数(内置常量 · 冻结):阈值 300 字符(一段 ∥ 一行同值——§3.18 ④);两项空列表 = 区带类 / 豁免类默认值且恒空
22
+ * ——豁免谓词(表行 / 围栏 / 列表项 / 引文)住代码,没有「登记后放过」的写入口(空列表 ≠ 可登记豁免)。 */
23
23
  export const DOC_CHECK_BUILTINS = Object.freeze({
24
- lineWidth: 300,
25
- widthExemptZones: Object.freeze([]),
24
+ paragraphMax: 300,
25
+ lineMax: 300,
26
+ paragraphExemptZones: Object.freeze([]),
26
27
  exemptions: Object.freeze([]),
27
28
  })
28
29
 
@@ -61,17 +62,20 @@ function resolveRange(base, docs) {
61
62
  return { check: check.sort(), undecidable, missing, notMarkdown, invalid }
62
63
  }
63
64
 
65
+ /** 段判据命中行(报面单源——两类命中同形,按 `clazz` 分词)。 */
66
+ const paragraphHitLine = (base, h) => `✗ ${h.clazz} ${rel(base, h.file)}:${h.line}(${h.len} 字符)`
67
+
64
68
  /** gate 报面(固定句形——§3.20「报面(固定句形 · 交付门 gate)」):首行 verdict = 唯一机判串。 */
65
- function gateReport({ base, docsSource, checkFiles, skipped, anchors, widths, lineWidth }) {
66
- const total = anchors.hits.length + widths.length
69
+ function gateReport({ base, docsSource, checkFiles, skipped, anchors, paragraphs, paragraphMax }) {
70
+ const total = anchors.hits.length + paragraphs.length
67
71
  const src = docsSource === "session" ? "本会话写过的文档" : "显式 docs"
68
72
  const out = [
69
73
  total ? `DOC-CHECK GATE: REFUSED(命中 ${total} 条)` : (checkFiles.length ? "DOC-CHECK GATE: PASS" : "DOC-CHECK GATE: PASS(0 档)"),
70
74
  "文档纪律机检(交付门 · gate)",
71
75
  checkFiles.length
72
- ? `范围:${src} ${checkFiles.length} 档 · 阈值 ${lineWidth} 字符(工具内置常量)`
73
- : `范围:${src} —— **0 档受检**(空清单:本次没有判任何档,不得读作「已检」)· 阈值 ${lineWidth} 字符(工具内置常量)`,
74
- "判据:工具内置常量(锚 ∥ 行宽)——判定范围 = 解析范围 = 传入档",
76
+ ? `范围:${src} ${checkFiles.length} 档 · 阈值 ${paragraphMax} 字符(工具内置常量)`
77
+ : `范围:${src} —— **0 档受检**(空清单:本次没有判任何档,不得读作「已检」)· 阈值 ${paragraphMax} 字符(工具内置常量)`,
78
+ "判据:工具内置常量(锚 ∥ 段判据)——判定范围 = 解析范围 = 传入档",
75
79
  ]
76
80
  if (skipped.missing.length) out.push(`跳过 ${skipped.missing.length} 档(已不在盘,未检):${skipped.missing.map((p) => rel(base, p)).join(" ∥ ")}`)
77
81
  if (skipped.notMarkdown.length) out.push(`跳过 ${skipped.notMarkdown.length} 档(非 markdown 档,未检):${skipped.notMarkdown.map((p) => rel(base, p)).join(" ∥ ")}`)
@@ -94,7 +98,7 @@ function gateReport({ base, docsSource, checkFiles, skipped, anchors, widths, li
94
98
  ? `✗ 锚·档缺 ${rel(base, h.file)}:${h.line} \`${h.token}\``
95
99
  : `✗ 锚·节缺 ${rel(base, h.file)}:${h.line} \`${h.token}\`(目标 ${rel(base, h.target)} 无 §${h.section})`)
96
100
  }
97
- for (const w of widths) out.push(`✗ 行宽 ${rel(base, w.file)}:${w.line}(${w.len} 字符)`)
101
+ for (const w of paragraphs) out.push(paragraphHitLine(base, w))
98
102
  out.push("处置:同轮修正后复跑(改文档 ∥ 修引擎);不转台账;拿不准 ⇒ 上抛主 agent 裁决(主 agent 拿不准 ⇒ 转呈用户)")
99
103
  }
100
104
  return out.join("\n")
@@ -109,8 +113,8 @@ function gateReport({ base, docsSource, checkFiles, skipped, anchors, widths, li
109
113
  * `PASS(0 档)` + 空范围说明(「本轮一档没碰」是真实读数);`report` 面不可判档 ⇒ 报错、整次零判定;
110
114
  * `gate` 面不可判档 ⇒ 跳过行;`docsSource` = 范围来源标签(`session` = 本会话触达记账 ∥ `explicit` = 显式
111
115
  * `docs`)——缺省 = `explicit`;只影响报面措辞。
112
- * @returns {{report:string, hits:{total:number, anchors:number, width:number}, anchorHits:Array,
113
- * widthHits:Array, files:string[], checkFiles:string[], lineWidth:number,
116
+ * @returns {{report:string, hits:{total:number, anchors:number, paragraph:number}, anchorHits:Array,
117
+ * paragraphHits:Array, files:string[], checkFiles:string[], paragraphMax:number,
114
118
  * skipped:{missing:string[], notMarkdown:string[]}}}
115
119
  * 消费面判「清」须读 `report` 首行(gate)∥ `hits.total`(report),不得只看其中一个。
116
120
  */
@@ -140,33 +144,33 @@ export function runDocCheck(root, { docs, mode = "report", docsSource } = {}) {
140
144
  throw new Error(`doc_check: docs names ${range.undecidable.length} document(s) that cannot be judged — no document was checked: ${named}. Fix the list and re-run; nothing is silently skipped and the check never widens beyond the list.`)
141
145
  }
142
146
  const checkFiles = range.check
143
- const { lineWidth, widthExemptZones } = DOC_CHECK_BUILTINS
147
+ const { paragraphMax, lineMax, paragraphExemptZones } = DOC_CHECK_BUILTINS
144
148
  const anchors = checkAnchors(base, { files: checkFiles })
145
- const widths = checkDocWidths({ lineWidth, exemptZones: widthExemptZones, files: checkFiles })
149
+ const paragraphs = checkParagraphs({ paragraphMax, lineMax, exemptZones: paragraphExemptZones, files: checkFiles })
146
150
 
147
- const hits = { total: anchors.hits.length + widths.length, anchors: anchors.hits.length, width: widths.length }
151
+ const hits = { total: anchors.hits.length + paragraphs.length, anchors: anchors.hits.length, paragraph: paragraphs.length }
148
152
  const shared = {
149
- hits, anchorHits: anchors.hits, widthHits: widths, files: checkFiles, checkFiles, lineWidth,
153
+ hits, anchorHits: anchors.hits, paragraphHits: paragraphs, files: checkFiles, checkFiles, paragraphMax,
150
154
  skipped: { missing: range.missing, notMarkdown: range.notMarkdown },
151
155
  }
152
156
  if (gate) {
153
- return { ...shared, report: gateReport({ base, docsSource: source, checkFiles, skipped: shared.skipped, anchors, widths, lineWidth }) }
157
+ return { ...shared, report: gateReport({ base, docsSource: source, checkFiles, skipped: shared.skipped, anchors, paragraphs, paragraphMax }) }
154
158
  }
155
159
 
156
160
  const out = [
157
161
  "文档纪律机检(报告态 · 非门禁)",
158
- `射程:${base} · 传入清单 ${checkFiles.length} 档 · 阈值 ${lineWidth} 字符(工具内置常量)· 区带豁免:${widthExemptZones.length ? widthExemptZones.join(" ∥ ") : "无"}`,
162
+ `射程:${base} · 传入清单 ${checkFiles.length} 档 · 阈值 ${paragraphMax} 字符(工具内置常量)· 区带豁免:${paragraphExemptZones.length ? paragraphExemptZones.join(" ∥ ") : "无"}`,
159
163
  "声明:判据参数 = 工具内置常量;范围 = 传入文件(判定范围 = 解析范围)",
160
164
  `范围:传入清单 ${checkFiles.length} 档(整档入判 · 判定范围 = 解析范围)`,
161
- `命中 ${hits.total} 条(锚 ${anchors.hits.length} · 行宽 ${widths.length})`,
165
+ `命中 ${hits.total} 条(锚 ${anchors.hits.length} · 段太长 ${paragraphs.filter((h) => h.clazz === "段太长").length} · 行太长 ${paragraphs.filter((h) => h.clazz === "行太长").length})`,
162
166
  ]
163
167
  for (const h of anchors.hits) {
164
168
  out.push(h.clazz === "档缺"
165
169
  ? `✗ 锚·档缺 ${rel(base, h.file)}:${h.line} \`${h.token}\``
166
170
  : `✗ 锚·节缺 ${rel(base, h.file)}:${h.line} \`${h.token}\`(目标 ${rel(base, h.target)} 无 §${h.section})`)
167
171
  }
168
- for (const w of widths) out.push(`✗ 行宽 ${rel(base, w.file)}:${w.line}(${w.len} 字符)`)
169
- out.push(hits.total ? `结论:${hits.total} 条命中——报告态,不作拒执行的理由` : "结论:0 条命中(锚 ∥ 行宽 均清)")
172
+ for (const w of paragraphs) out.push(paragraphHitLine(base, w))
173
+ out.push(hits.total ? `结论:${hits.total} 条命中——报告态,不作拒执行的理由` : "结论:0 条命中(锚 ∥ 段太长 ∥ 行太长 均清)")
170
174
 
171
175
  return { ...shared, report: out.join("\n") }
172
176
  }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * doc-check/paragraph.mjs — 文档纪律机检的产品档位(段判据谓词族 + 段判据——实现单源)。
3
+ *
4
+ * 口径单源 = `docs/core/design/DOC-DISCIPLINE.md` §3.18 条目 Q(单位是段 / 面谓词 / 阈值 / 不分域);
5
+ * 产品内建裁定 = 同档 §3.20 条目 S——谓词单源随迁:本仓 `scripts/doc-check-paragraph.mjs` 改述为
6
+ * **再出口缝**(薄档 · 零逻辑 · 逐名再出口),盘上不留第二份谓词实现。
7
+ * 两面切块:文档面量段(相邻非空正文行合并成一段,量段长)· 提示词面(`isPromptFace`)量行
8
+ * (逐行裸判,无结构豁免)。度量 = `[...line].length`(CJK 计 1——不引显示宽度 / 字体);
9
+ * 阈值 = 调用方的内置常量(`DOC_CHECK_BUILTINS.paragraphMax` / `lineMax`)——不读声明面,也不扫目录:
10
+ * **判定集 = 调用方传入的文件清单**。
11
+ * 不计入段的行类(同时把段断开)= 空行 ∥ 表格行 ∥ 标题 ∥ 列表项 ∥ 引用行 ∥ 围栏(标记行与块内整行)
12
+ * ∥ 区带内:只认结构谓词,无行级白名单(按行号 / 注释声明的豁免不入判);可执行行在段判据里**不豁免**
13
+ * (段 = 读者所见,码段渲染后仍在段内——§3.18 关键决策 ①)。
14
+ * 边界:本档零门禁 / 零退出码 / 零阻断——报告态由 `agent-tools/doc-check.mjs` 呈现。
15
+ */
16
+ import { readFileSync } from "node:fs"
17
+
18
+ /** 表格行谓词(markdown 表格行结构性不可折行——不计入段,与锚检查同源)。 */
19
+ export const isTableRow = (l) => /^\s*\|.*\|\s*$/.test(String(l))
20
+
21
+ /** 围栏标记(行首可选空白 + 三反引号 ∥ 三波浪号 ⇒ 标记字符;非围栏行 ⇒ null)——**单源**:
22
+ * `isFenceLine` 与两处块扫描(本节段判据 ∥ `./anchors.mjs` 锚抽取)同取此式,使「同形围栏行止」只此一解
23
+ * (同一模块内同符两义 = 单源可漂的结构口子)。 */
24
+ export function fenceMark(l) {
25
+ const m = /^\s*(`{3,}|~{3,})/.exec(String(l))
26
+ return m ? m[1][0] : null
27
+ }
28
+
29
+ /** 围栏行谓词(单源 = `fenceMark`)——围栏行自身与块内整行均不计入段。 */
30
+ export const isFenceLine = (l) => fenceMark(l) !== null
31
+
32
+ /** 引文块行谓词(行首可选空白 + `>`)——连续 `>` 行整块不计入段(引证须逐字连续、不可折,同表行理据)。 */
33
+ export const isQuoteLine = (l) => /^\s*>/.test(String(l))
34
+
35
+ /** 列表项谓词(无序 `-` ∥ `*` ∥ `+` · 有序 `数字 + . ∥ ) ∥ 、` + 空白)——列表项不计入段且断段。 */
36
+ export const isListItem = (l) => /^\s*(?:[-*+]|\d+[.)、])\s/.test(String(l))
37
+
38
+ /** 提示词面谓词(`thincoder-core/prompts/**`——形态对齐 `./anchors.mjs` 的 `OPERAND_FACE`,单源住本档):
39
+ * 模型读原始文本,一行就是一个块(FR46 同源理由)⇒ 该面逐行裸判,无结构豁免。 */
40
+ export const isPromptFace = (abs) => /(?:^|\/)thincoder-core\/prompts\//i.test(String(abs).replace(/\\/g, "/"))
41
+
42
+ /** 区带头谓词(§3.15 谓词面):标题文本去首部编号(`^\d+(?:\.\d+)*[.、]?\s*`)后——等于区带名 ∨ 以
43
+ * 「区带名 + (∕(」起(容括注后缀,如 `变更记录(续)`);闭集外一律不纳(fail-closed)。 */
44
+ export function isZoneHead(headingText, zones = []) {
45
+ const text = String(headingText).replace(/^\d+(?:\.\d+)*[.、]?\s*/, "")
46
+ return zones.some((z) => typeof z === "string" && z !== "" && (text === z || text.startsWith(z + "(") || text.startsWith(z + "(")))
47
+ }
48
+
49
+ /** 行内可执行坐标(命令 / 搜索模式串码段)——锚判据整行跳过用(证据与命令保字面);段判据不豁免(段 = 读者所见)。 */
50
+ export function isExecutableLine(line) {
51
+ const l = String(line)
52
+ if (/`[^`\n]*&&[^`\n]*`/.test(l)) return true // 命令链(cd … && …)
53
+ if (/`[^`\n]*\b(?:cd|node|npm|npx|grep|rg|git)\s[^`\n]*`/.test(l)) return true // 码段内命令词
54
+ if (/`[^`\n]*(?:\[\^|\\\.|\(\?<|\(\?:|\{\d)/.test(l)) return true // 码段内搜索模式串片段
55
+ return false
56
+ }
57
+
58
+ /** 行内位置是否在反引号码段内(引述面不判)。 */
59
+ export function inCodeSpan(line, idx) {
60
+ return (String(line).slice(0, idx).match(/`/g) ?? []).length % 2 === 1
61
+ }
62
+
63
+ /** 段判据(阈值 = 调用方内置常量 `paragraphMax` ∥ `lineMax`;口径逐字 = §3.18 条目 Q):
64
+ * · 文档面:相邻非空正文行合并成一段,段长 = 组成行 `[...line].length` 之和 > `paragraphMax` ⇔ 命中
65
+ * (`clazz: "段太长"`,定位 = 段首行号)。正文行 = 非空 ∧ 非表格行 ∧ 非标题 ∧ 非列表项 ∧ 非引用行
66
+ * ∧ 非围栏(标记行与块内整行)∧ 非区带内;其余行类不计入、且把段断开。
67
+ * · 提示词面(`isPromptFace`):逐行裸判——`[...line].length` > `lineMax` ⇔ 命中(`clazz: "行太长"`,
68
+ * 无结构豁免)。
69
+ * 单遍扫描携标题栈(`^(#{1,6})\s` 行入栈——同级 ∥ 更高级出栈):行所属标题链任一为区带头 ⇒ 区带内
70
+ * (不计入段且断段)。围栏未闭合 ⇒ 至档尾整块免(fail-open 方向——§3.18 残余登记 ①);
71
+ * `exemptZones` 缺省 ∥ 空集 ⇒ 零豁免。`files`(必填)= **判定集**(调用方传入的档清单——本档不遍历
72
+ * 任何目录;空集 ⇒ 零命中)。 */
73
+ export function checkParagraphs({ paragraphMax, lineMax, exemptZones = [], files = [] } = {}) {
74
+ const zones = (Array.isArray(exemptZones) ? exemptZones : []).filter((z) => typeof z === "string" && z !== "")
75
+ const hits = []
76
+ for (const f of files) {
77
+ let text = ""
78
+ try { text = readFileSync(f, "utf8") } catch { continue }
79
+ if (isPromptFace(f)) {
80
+ text.split("\n").forEach((l, i) => {
81
+ const len = [...l].length
82
+ if (len > lineMax) hits.push({ file: f, line: i + 1, len, clazz: "行太长" })
83
+ })
84
+ continue
85
+ }
86
+ const stack = [] // 标题栈 —— { level, exempt };exempt = 自身 ∥ 祖先命中区带
87
+ let fence = null // 围栏标记(` ∥ ~);null = 不在块内
88
+ let para = null // 在写的段 —— { start, len };非正文行 ⇒ 断开
89
+ const lines = text.split("\n")
90
+ const flush = () => {
91
+ if (para !== null && para.len > paragraphMax) hits.push({ file: f, line: para.start, len: para.len, clazz: "段太长" })
92
+ para = null
93
+ }
94
+ lines.forEach((l, i) => {
95
+ const mark = fenceMark(l)
96
+ if (mark !== null) {
97
+ if (fence === null) fence = mark
98
+ else if (mark === fence) fence = null
99
+ flush()
100
+ return // 围栏标记行自身断段
101
+ }
102
+ if (fence !== null) { flush(); return } // 块内整行断段(未闭合 ⇒ 至档尾免)
103
+ const hm = /^(#{1,6})\s+(.*)$/.exec(l)
104
+ if (hm) {
105
+ while (stack.length && stack[stack.length - 1].level >= hm[1].length) stack.pop()
106
+ const self = isZoneHead(hm[2].trim(), zones)
107
+ stack.push({ level: hm[1].length, exempt: self || (stack[stack.length - 1]?.exempt ?? false) })
108
+ flush()
109
+ return // 标题断段
110
+ }
111
+ const inZone = stack[stack.length - 1]?.exempt === true
112
+ if (inZone || l.trim() === "" || isTableRow(l) || isListItem(l) || isQuoteLine(l)) { flush(); return }
113
+ if (para === null) para = { start: i + 1, len: 0 }
114
+ para.len += [...l].length
115
+ })
116
+ flush()
117
+ }
118
+ return hits
119
+ }
package/fts-text.mjs CHANGED
@@ -30,7 +30,10 @@ export function segmentCJK(text) {
30
30
  * 无 token ⇒ 返回空串(消费侧据此退化——SESSION.md §6.19 边界情形表)。
31
31
  */
32
32
  export function buildFtsQuery(query) {
33
- const terms = query
33
+ // 模型偶尔把 query 传成数组 / 对象(schema 声明是 string):先按文本归一。
34
+ // 不归一的话 query.split 直接抛 TypeError,整个回合崩掉(GitHub #30)。
35
+ const text = Array.isArray(query) ? query.join(" ") : query == null ? "" : String(query)
36
+ const terms = text
34
37
  .split(/[\s,,。、;;!!??()()"`]+/)
35
38
  .map((t) => t.trim())
36
39
  .filter(Boolean)
package/ledger-cmd.mjs CHANGED
@@ -12,6 +12,9 @@
12
12
  * 「档此刻在不在盘」降为读数——读面导出 `ledgerBatchRows`、收口重锚单源 `ledgerReanchorBatch`。
13
13
  * 批量面(§3.3 P1):选行单源 `resolveBatchRows` + 逐行落笔 `applyRowUpdate` / `applyRowClose`
14
14
  * (与单行形共函数——迁移表判 / 写门 / evidence 门逐行照判)。
15
+ * 核销标记门(LIGHT-CHANNEL.md §2.5 · 台账 #1780):写入 `已核销` 的三径(close 单行 ∥ close 批量 ∥
16
+ * update 迁移——均经两逐行共函数)判**结果行标题**携 `ROUND_MARKER` ⇒ 拒;判据只认标题标记
17
+ * (不查批档 ∥ 不查评审实跑),撤回(→ 已废弃)不拦。
15
18
  *
16
19
  * 接线(设计档 §2.2 命令面接线):族出口 = `ledger.mjs`(re-export 两源——KD-M2-3);查询命令 ←
17
20
  * tools/index.mjs(assembleBuiltinTools 全角色面);写命令 ← agent/family-tools.mjs(assembleFamilyTools
@@ -19,7 +22,7 @@
19
22
  * 消费侧静态 import 会破 W8 契约②)。
20
23
  */
21
24
  import { resolve } from "node:path"
22
- import { AGING_DAYS, ALLOWED_MIGRATIONS, EVIDENCE_SOFT_LIMIT, nowIso, openLedger, PENDING_STATUSES, resolveLedgerRoot } from "./ledger-db.mjs"
25
+ import { AGING_DAYS, ALLOWED_MIGRATIONS, EVIDENCE_SOFT_LIMIT, nowIso, openLedger, PENDING_STATUSES, resolveLedgerRoot, ROUND_MARKER } from "./ledger-db.mjs"
23
26
  import { resolveDeclaredRef } from "./declaration.mjs"
24
27
 
25
28
  /** 逐行老化判真(LEDGER.md §13.4 · 2026-10-07 批 ledger-tool):未决四态(`PENDING_STATUSES`)∧
@@ -210,8 +213,9 @@ function assertEvidenceValue(fnName, evidence) {
210
213
  if (evidence != null && String(evidence).trim() === "") throw new Error(`${fnName}:evidence 为空(非空字符串)`)
211
214
  }
212
215
 
213
- /** 单行落笔·update(**单行形 ∥ 批量形共函数**——设计档 §3.3「逐行语义单源」):迁移表判 → executor /
214
- * evidence 值计算(含两拒面)→ 写门 → UPDATE(判序 = §13.9 逐字未变)。`to` = `patch.status ?? 行现态`
216
+ /** 单行落笔·update(**单行形 ∥ 批量形共函数**——设计档 §3.3「逐行语义单源」):迁移表判 → 核销标记门
217
+ * (目标 = `已核销` ∧ 结果标题携标记 ⇒ 拒——判序 = LIGHT-CHANNEL.md §2.5)→ executor / evidence 值计算
218
+ * (含两拒面)→ 写门 → UPDATE(判序 = §13.9 逐字未变)。`to` = `patch.status ?? 行现态`
215
219
  * ——批量形纯字段补丁(不携 `status`)时 `to === row.status` ⇒ 迁移表不判、`status` 零写(§3.3)。
216
220
  * `resume_when` = 普通可空字段补丁(`patch.resume_when ?? 行值`——§3.4)。 */
217
221
  function applyRowUpdate(db, cwd, row, patch, executorSessionId) {
@@ -219,6 +223,10 @@ function applyRowUpdate(db, cwd, row, patch, executorSessionId) {
219
223
  if (to !== row.status && !(ALLOWED_MIGRATIONS[row.status] ?? []).includes(to)) {
220
224
  throw new Error(`ledgerUpdate:迁移 ${row.status} → ${to} 不在允许迁移表`)
221
225
  }
226
+ // 核销标记门(LIGHT-CHANNEL.md §2.5):判**结果标题**——一笔同携清标记 title + status=已核销 ⇒ 放行
227
+ if (to === "已核销" && String(patch.title ?? row.title ?? "").includes(ROUND_MARKER)) {
228
+ throw new Error(`ledgerUpdate:行标题仍携「${ROUND_MARKER}」——收尾链未跑完,不许核销(先跑收尾全链,再由父侧清标记)`)
229
+ }
222
230
  const nextExecutor = resolveExecutorTarget(row, to, patch, executorSessionId)
223
231
  const nextEvidence = evidenceValue("ledgerUpdate", row.evidence, patch.evidence, patch.evidenceReplace)
224
232
  assertEvidenceValue("ledgerUpdate", patch.evidence)
@@ -252,11 +260,16 @@ export function ledgerUpdate({ cwd, id, patch, executorSessionId }) {
252
260
  }
253
261
 
254
262
  /** 单行落笔·close(**单行形 ∥ 批量形共函数**——设计档 §3.3「逐行语义单源」):源态判(核销仅三源——
255
- * 在途不可跳)→ 值计算(拒面① 旗独传判于此)→ 追认口 `evidence` 门 → 拒面② → UPDATE。
263
+ * 在途不可跳)→ 核销标记门(LIGHT-CHANNEL.md §2.5——`已核销` ∧ 标题携标记 ⇒ 拒;撤回不拦)→
264
+ * 值计算(拒面① 旗独传判于此)→ 追认口 `evidence` 门 → 拒面② → UPDATE。
256
265
  * 撤回(任意态 → 已废弃——含在途直撤)同步 `executor = NULL`(LEDGER.md §3.1 ④);核销两源(勾销 /
257
266
  * 追认)`executor` 零触碰(非在途出边)。 */
258
267
  function applyRowClose(db, row, status, evidence, evidenceReplace) {
259
268
  if (status === "已核销" && !["待讨论", "待设计", "待核销"].includes(row.status)) throw new Error(`ledgerClose:核销仅限 待讨论 / 待设计 / 待核销(现态 ${row.status})`)
269
+ // 核销标记门(LIGHT-CHANNEL.md §2.5):勾销 ∥ 追认同判;撤回(→ 已废弃)不入本门
270
+ if (status === "已核销" && String(row.title ?? "").includes(ROUND_MARKER)) {
271
+ throw new Error(`ledgerClose:行标题仍携「${ROUND_MARKER}」——收尾链未跑完,不许核销(先跑收尾全链,再由父侧清标记)`)
272
+ }
260
273
  const nextEvidence = evidenceValue("ledgerClose", row.evidence, evidence, evidenceReplace)
261
274
  if (status === "已核销" && ["待讨论", "待设计"].includes(row.status) && (nextEvidence == null || String(nextEvidence).trim() === "")) throw new Error(`ledgerClose:追认核销须带 evidence(现态 ${row.status})`)
262
275
  assertEvidenceValue("ledgerClose", evidence) // 判序:拒面②(新值空白)落追认门之后 → UPDATE
package/ledger-db.mjs CHANGED
@@ -87,23 +87,24 @@ export function ledgerChildren(dir) {
87
87
  }
88
88
 
89
89
  /** 项目根推导**单源**(设计档 §2.1「根解析面」/ §3.8——读写同一判据、同一实现):
90
- * 自 `target`(「动作目标路径」;缺省 = `.`)起沿**祖先链**向上(含自身)取**最近一个「已注册」目录**
91
- * ⇒ `{ root, basis: 'ancestor' }`(自明——回执逐字零改)。
92
- * 祖先链无命中 ∧ `descend: true` ⇒ **向下收敛**锚的直接子目录(`ledgerChildren` 同款惰性枚举 +
90
+ * 自 `target` 沿**祖先链**上溯取最近「已注册」目录——**以会话锚(`process.cwd()`)为界,越界即停**:
91
+ * 界内上溯 = 子目录归到自己的项目根(应该);越过锚 = 挂到无关外层项目上(禁止)。
92
+ * 命中 ⇒ `{ root, basis: 'self' | 'ancestor' }`(自身 vs 界内祖先);
93
+ * 界内无命中 ∧ `descend: true` ⇒ **向下收敛**锚的直接子目录(`ledgerChildren` 同款惰性枚举 +
93
94
  * 上限、名序升序):恰一 ⇒ 取之;≥2 ⇒ ① 先取**有键控库**者(账已在盘 = 事实)② 该集名序首者 ⇒
94
95
  * `{ root, basis: 'descendant' }`(向下发现——**须报明**);零已注册子目录 ⇒ `null`(无根)。
95
- * `descend: false` ⇒ 只问祖先支(族发现 ∥ 标记面——容器 ∥ 工作区锚 ⇒ `null` ⇒ 族求和)。
96
+ * `descend: false` ⇒ 只问界内上溯支(族发现 ∥ 标记面——容器 ∥ 工作区锚 ⇒ `null` ⇒ 族求和)。
96
97
  * **无自指**:内部「库在盘」判据只用 `ledgerKey` + `keyedDbFile`(不经 `ledgerDbPath`)。
97
98
  * @param {string} [target] 动作目标路径(目录)
98
99
  * @param {{descend?: boolean}} [opts] `descend` 缺省 `true`(动作面——`openLedger` / `ledgerDbPath` / 单范围读)
99
- * @returns {{root: string, basis: 'ancestor'|'descendant'}|null} 项目根 + 判据来源 / 无根(`null`) */
100
+ * @returns {{root: string, basis: 'self'|'ancestor'|'descendant'}|null} 项目根 + 判据来源 / 无根(`null`) */
100
101
  export function resolveLedgerRoot(target, { descend = true } = {}) {
101
102
  const start = resolve(target ?? ".")
102
103
  let dir = start
103
104
  for (;;) {
104
- if (isRegistered(dir)) return { root: dir, basis: "ancestor" }
105
+ if (isRegistered(dir)) return { root: dir, basis: dir === start ? "self" : "ancestor" }
105
106
  const parent = dirname(dir)
106
- if (parent === dir) break // 盘根 → 祖先链无已注册者
107
+ if (parent === dir) break
107
108
  dir = parent
108
109
  }
109
110
  if (!descend) return null
@@ -116,7 +117,7 @@ export function resolveLedgerRoot(target, { descend = true } = {}) {
116
117
 
117
118
  /** 无根文案(单源——设计档 §3.8「无根契约」逐字;`openLedger` 写面 ∥ `ledgerDbPath` 共用同一句)。 */
118
119
  const noRootMessage = (start) =>
119
- `台账:自 ${start} 未能推导项目根——祖先链与本级子目录均无已注册项目(带 PROJECT-MANIFEST.json 或已有键控台账库)`
120
+ `台账:自 ${start} 未能推导项目根——该目录与本级子目录均无已注册项目(带 PROJECT-MANIFEST.json 或已有键控台账库)`
120
121
 
121
122
  /** 项目根 → 库路径(稳定键 = `ledgerKey` 前 16 位——路径唯一标识,不含分隔符)。
122
123
  * **归单源**(2026-10-16 批 ledger-root-derivation):根 = `resolveLedgerRoot(cwd)`——与读面 / 写面
@@ -133,6 +134,11 @@ export function ledgerDbPath(cwd) {
133
134
  /** 未决四态(「活条目」口径 = 计数单源 WHERE 集;已核销 / 已废弃 = 归档态)。 */
134
135
  export const PENDING_STATUSES = ["待讨论", "待设计", "在途", "待核销"]
135
136
 
137
+ /** 轻通道轮次行标记(单源——LIGHT-CHANNEL.md §2.3 开轮挂账 ∥ §2.8 闸凭据 ∥ §2.5 核销标记门
138
+ * 判据三面同一字面;消费面 = `agent/light-round.mjs`(经 `ledger.mjs` re-export 动态解构——
139
+ * W8 契约②零新静态边)∥ `ledger-cmd.mjs` 核销标记门)。 */
140
+ export const ROUND_MARKER = "收尾链待跑"
141
+
136
142
  /** 老化阈值(天——口径 = 需求档;消费面 = `buildScan`(展示,`days` 可覆盖)+ `ledgerQuery` 逐行
137
143
  * `aged`(查询面,`now` 可注入))。2026-10-07 批 ledger-tool:自 `ledger.mjs` 迁入(枚举常量档单源
138
144
  * ——`ledgerQuery` 计算面直引本档,消 `ledger-cmd ↔ ledger.mjs` 新环;`ledger.mjs` 同名 re-export
package/ledger.mjs CHANGED
@@ -151,7 +151,7 @@ export function detailScans(scans, current) {
151
151
  // ── re-export(拆分件接口——命令面接线 = 动态 import 本档,KD-M2-3;`AGING_DAYS` 常量单源 = `ledger-db.mjs`
152
152
  // ——2026-10-07 批 ledger-tool 自本档迁入,公共面零变;`ledgerChildren` / `MAX_SIBLING_SCAN` / `resolveLedgerRoot`
153
153
  // ——2026-10-16 批 ledger-root-derivation 随迁 / 新增,公共面形零改) ──
154
- export { AGING_DAYS, ALLOWED_MIGRATIONS, EVIDENCE_SOFT_LIMIT, MAX_SIBLING_SCAN, ledgerChildren, ledgerDbPath, ledgerKey, ledgerDirPath, openLedger, PENDING_STATUSES, resolveLedgerRoot, _setLedgerDirForTest, _resetLedgerDirForTest, ensureColumn, ensureExecutorColumn } from "./ledger-db.mjs"
154
+ export { AGING_DAYS, ALLOWED_MIGRATIONS, EVIDENCE_SOFT_LIMIT, MAX_SIBLING_SCAN, ledgerChildren, ledgerDbPath, ledgerKey, ledgerDirPath, openLedger, PENDING_STATUSES, resolveLedgerRoot, ROUND_MARKER, _setLedgerDirForTest, _resetLedgerDirForTest, ensureColumn, ensureExecutorColumn } from "./ledger-db.mjs"
155
155
  export { ledgerAdd, ledgerBatchRows, ledgerClose, ledgerCloseBatch, ledgerCount, ledgerEvidenceOversize, ledgerQuery, ledgerReanchorBatch, ledgerUpdate, ledgerUpdateBatch, overdueOf } from "./ledger-cmd.mjs"
156
156
  export { ledgerTool, ledgerReadTool, ledgerAddTool, ledgerCloseTool, ledgerCountTool, ledgerQueryTool, ledgerUpdateTool } from "./ledger-tools.mjs"
157
157
  export { runLedgerAudit, runLedgerMigrate } from "./ledger-migrate.mjs"
@@ -9,7 +9,7 @@
9
9
  * (`MANIFEST_REL` ∥ `discoverProjects` ∥ `owningProject` ∥ `resolveProjectRoot`)随 import。
10
10
  * 零环:本档不引宿主(方向单行);档外依赖 = `node:fs` ∥ `node:path`;机制详述 = `docs/core/design/MANIFEST.md` §2.2 / §2.9。
11
11
  */
12
- import { existsSync, readdirSync } from "node:fs"
12
+ import { existsSync, readdirSync, statSync } from "node:fs"
13
13
  import { dirname, join, resolve } from "node:path"
14
14
 
15
15
  /** 数据档文件名(**每个项目一份**——项目根 = 带档目录;**git 非前提**。2026-09-17 / 2026-09-21 用户裁定)。 */
@@ -88,27 +88,36 @@ export function discoverRepos(cwd) {
88
88
  }
89
89
 
90
90
  /**
91
- * **归属形单点**(KD-M1-30 / 设计 §2.9 A——2026-09-21 用户裁定「最近者优先」):自 `target`
92
- * (目录含自身;文件路径自其父目录起)沿**祖先链**逐级上溯至盘根,取**最近**带 `MANIFEST_REL`
93
- * 的目录;无 ⇒ `null`(⇒ 调用方走发现兜底)。**嵌套合法**:子内归子、根其余归根。
94
- * **纯 fs**(只判存在性——不解析档内容 / 不问模式)· **不跨兄弟** · **无全局优先级**。
95
- * **覆盖位**(`_setProjectRootForTest` 在场)⇒ **头部短路**:直接返回覆盖值(不查档存在性——
96
- * 保「覆盖即覆盖值」语义,回归守卫 = 本批单元测试档基座用例(`docs/batches/2026-10-02-manifest-resolution-fix.test.mjs`——
97
- * 基座三态 + 覆盖位 + 薄委托等价逐格)+ `docs/batches/2026-09-29-structure-split-2.test.mjs` 导出枚举探针(`Object.keys` 名面核))。
91
+ * **归属形单点**(KD-M1-30 · 2026-10-23 用户裁定):自 `target`(目录 = 自身;文件 = 其所在目录)
92
+ * 沿**祖先链**上溯,取最近带 `MANIFEST_REL` 的目录 ⇒ 该目录;无 ⇒ `null`(交发现兜底)。
93
+ * 会话落在项目内层子目录时,这一路上溯正是它归到自己项目根的通路(用户 2026-10-23 23:24
94
+ * 「本来就该爬到项目根啊」);**发现梯(`discoverProjects`)仍纯向下**——从工作区容器只找直接
95
+ * 子目录,不把容器挂到外层项目上。**纯 fs** · **不跨兄弟** · **无全局优先级**。
96
+ * **覆盖位**(`_setProjectRootForTest` 在场)⇒ **头部短路**:直接返回覆盖值(不查档存在性)。
98
97
  * @param {string} [target] 目标路径(目录 / 文件)
99
- * @returns {string|null} 最近带档祖先目录绝对路径 / null
98
+ * @returns {string|null} 带档目录绝对路径 / null
100
99
  */
101
100
  export function owningProject(target) {
102
101
  if (_projectRootOverride) return resolve(_projectRootOverride)
103
102
  let dir = resolve(target ?? ".")
103
+ const st = statSync(dir, { throwIfNoEntry: false })
104
+ if (st && !st.isDirectory()) dir = dirname(dir)
104
105
  for (;;) {
105
106
  if (existsSync(join(dir, MANIFEST_REL))) return dir
106
107
  const parent = dirname(dir)
107
- if (parent === dir) return null // 盘根 → 祖先链无档
108
+ if (parent === dir) return null
108
109
  dir = parent
109
110
  }
110
111
  }
111
112
 
113
+ /** `dir` 是否等于 `bound` 或在其子树内(分隔符归一比较)。 */
114
+ function isInsideOrEqual(dir, bound) {
115
+ const norm = (p) => p.replaceAll("\\", "/").replace(/\/+$/, "")
116
+ const d = norm(dir)
117
+ const b = norm(bound)
118
+ return d === b || d.startsWith(b + "/")
119
+ }
120
+
112
121
  /** **项目根解析的直读形**(判定单点——设计 `MANIFEST.md` §2.2 `projectRootView` 契约行 / §2.5
113
122
  * 「单项目解析律与四处会话级缺省收口」条):`ok`(唯一解析:归属命中 ∥ 发现 `self` / `unique`)/
114
123
  * `workspace`(锚自身无档 ∧ 下辖 **≥2** 项目 ⇒ `root` null + 下辖项目全列**按名排序**——工作区
package/manifest.mjs CHANGED
@@ -135,13 +135,13 @@ export function manifestFilePath(cwd) {
135
135
  /** #802(KD-M1-36)未知键告警去重 memo:档路径 → 上次观测键集(进程内);读到零未知键即清该条。 */
136
136
  const unknownKeysSeen = new Map()
137
137
 
138
- /** 读面一行可见告警(#802):含键名;同档同键集恰一次——console.warn + logEvent('manifest:unknown-keys')。 */
138
+ /** 未知键诊断(#802 · 2026-10-23 改):**不上界面**——只写 logEvent('manifest:unknown-keys');同档同键集恰一次。 */
139
139
  function warnUnknownKeys(file, unknownKeys) {
140
140
  if (unknownKeys.length === 0) { unknownKeysSeen.delete(file); return }
141
141
  const sig = JSON.stringify([...unknownKeys].sort())
142
142
  if (unknownKeysSeen.get(file) === sig) return
143
143
  unknownKeysSeen.set(file, sig)
144
- console.warn(`[manifest] ${MANIFEST_REL} unknown key(s) ignored: ${unknownKeys.join(", ")} — not an error; check spelling / version mismatch (KD-M1-36)`)
144
+ // 不上界面(2026-10-23 用户裁定:换别的项目也不能弹,不许骚扰用户);只写诊断日志,`manifest read` 时才出现在结果里
145
145
  logEvent("manifest:unknown-keys", { path: file, keys: unknownKeys.join(",") })
146
146
  }
147
147
 
@@ -264,16 +264,28 @@ function workspaceProjectMessage(cwd, candidates) {
264
264
  * 不替被开发项目创建目录(§1.4 边界)——cwd 不存在时写失败自然上抛。
265
265
  * @returns {object} 传入的 manifest(校验通过后原样落盘)
266
266
  */
267
+ /** 已退役的顶层键(FR42 八键 → 五键):写入时静默丢弃——它们不是用户拼错,是产品自己退场的键,
268
+ * 留着只会让读面反复报「未知键」骚扰用户(2026-10-23 用户裁定「死键你删掉啊」)。 */
269
+ const RETIRED_KEYS = ["promptsLanding", "checkConfig", "advisor"]
270
+
271
+ /** 返回去掉退役键的新对象(不改入参)。 */
272
+ function stripRetiredKeys(obj) {
273
+ const out = { ...obj }
274
+ for (const k of RETIRED_KEYS) delete out[k]
275
+ return out
276
+ }
277
+
267
278
  export function writeManifest(cwd, manifest, { writer = "subagent" } = {}) {
268
279
  if (writer !== "main") {
269
280
  throw new Error("writeManifest 拒绝:非主 agent 无写权(writer 须为 \"main\",fail-closed 缺省拒)")
270
281
  }
271
- const validated = validateManifest(manifest)
282
+ const cleaned = stripRetiredKeys(manifest)
283
+ const validated = validateManifest(cleaned)
272
284
  if (!validated.ok) {
273
285
  throw new Error(`writeManifest 拒绝:manifest 校验不过——${validated.errors.join(";")}`)
274
286
  }
275
- writeFileSync(manifestFilePath(cwd), JSON.stringify(manifest, null, 2) + "\n")
276
- return manifest
287
+ writeFileSync(manifestFilePath(cwd), JSON.stringify(cleaned, null, 2) + "\n")
288
+ return cleaned
277
289
  }
278
290
 
279
291
  /**
@@ -0,0 +1,98 @@
1
+ /**
2
+ * model-context.mjs — 模型上下文解析的运行时事实面(`PROVIDER.md` §6.15 · 五级链 L1/L3 两层来源)。
3
+ *
4
+ * 组成三件:模块级 `baseURL → (归一 id → 条目表)` 只读缓存 · 键归一(末段 + 小写)· 值域校验
5
+ * (`positiveInt`)。零本地 import(只碰运行时内建)⇒ 零 import 环。
6
+ *
7
+ * - L1 `providers[].modelContext[模型]`(人工声明,**K** 单位)→ `manualContextK`(返 K)
8
+ * - L3 `/models` 自报窗口(**token**)→ `listedContextTokens`(返 token)
9
+ * 单位进函数名:乘点唯一在 `model-specs.mjs`(L1 ∥ L2 ×1024,L3 不乘)——读函数名即知单位。
10
+ *
11
+ * 缓存触发 = 既有列表拉取(写面只在 `provider/list-models.mjs` 成功返回时调 `recordListedContext`):
12
+ * 六个消费面本来就要拉列表,顺带把窗口留在核里,**零新增请求**(D2)。读面是同步纯查表:未命中返
13
+ * `null`、调用侧落下一层,永不抛、永不等待(R28)。
14
+ *
15
+ * 键归一 = 末段 + 小写(规则单源 = `PROVIDER.md` §6.9):`qwen/ZHIPU/GLM-5.3` ⇒ `glm-5.3`。
16
+ * **本面算子 = 末段相等**(不是前缀):用户键 `glm-5.3` 不命中模型 `glm-5.3-flashx`(R29③)。
17
+ * 同侧两条键归一后相撞 = 同一模型的两种写法 ⇒ **命中即取**(对象键序 ∥ 列表序第一条,不排序、
18
+ * 不比较;R29④)——不拒、不告警、不落下一层。
19
+ */
20
+
21
+ /** 值域校验单点(L1 K ∥ L3 token 共用谓词):正整数才收,其余一律 `null`(= 未提供)。
22
+ * 只认 `number`——写面交互输入是字符串,须先 `Number(String(v).trim())` 再判(调用侧责任)。 */
23
+ export function positiveInt(v) {
24
+ return typeof v === "number" && Number.isInteger(v) && v > 0 ? v : null
25
+ }
26
+
27
+ /** 模型名归一(末段 + 小写——§6.9 三面同一把尺子)。非串归一等价空串(脏载不抛)。 */
28
+ export function normalizeModelKey(name) {
29
+ const m = (typeof name === "string" ? name : "").toLowerCase()
30
+ const slash = m.lastIndexOf("/")
31
+ return slash >= 0 ? m.slice(slash + 1) : m
32
+ }
33
+
34
+ function normalizeBaseURL(baseURL) {
35
+ return (typeof baseURL === "string" ? baseURL : "").replace(/\/+$/, "")
36
+ }
37
+
38
+ /** 告警一次(模块级去重键)——本模块唯一告警出口;去重键决定「一次」的射程。 */
39
+ const warned = new Set()
40
+ export function warnContextOnce(key, message) {
41
+ if (warned.has(key)) return
42
+ warned.add(key)
43
+ console.warn(message)
44
+ }
45
+
46
+ // ─── L3 缓存:归一 baseURL → (归一 id → 条目表) ───────────────────────────────────
47
+ // 键 = 归一后的 baseURL(列表是端点的事实,不是本地别名的事实——探针目标常缺 name)。
48
+ // 写 = 每次成功拉取**整条替换**(不合并):列表不再报某模型 ⇒ 该模型回到 L4;拉取失败不写、
49
+ // 不清空上次成功值(上次自报比静态表更接近事实)。归一索引在写时建好 ⇒ 读面是一次 `Map` 查表
50
+ // (同键多条目 = 末段相撞,读面取列表序第一条——R29④)。
51
+ const listedByEndpoint = new Map()
52
+
53
+ /** L3 写面(唯一入口):记录一个端点的列表自报窗口表。`entries` = `[id, tokens]` 可迭代
54
+ * (Map ∥ 数组);非法值当场丢弃(= 该模型列表未报)。空表也写入——「这次一条都没报」是事实。 */
55
+ export function recordListedContext(baseURL, entries) {
56
+ const base = normalizeBaseURL(baseURL)
57
+ if (!base) return
58
+ const index = new Map()
59
+ for (const [id, tokens] of entries ?? []) {
60
+ if (typeof id !== "string" || !id) continue
61
+ const value = positiveInt(tokens)
62
+ if (value === null) continue
63
+ const key = normalizeModelKey(id)
64
+ if (!key) continue
65
+ const bucket = index.get(key)
66
+ if (bucket) bucket.push({ id, tokens: value })
67
+ else index.set(key, [{ id, tokens: value }])
68
+ }
69
+ listedByEndpoint.set(base, index)
70
+ }
71
+
72
+ /** L3 读面:列表自报窗口(token,不乘)。未报 ⇒ `null`(落下一层);同端点两 id 末段相撞
73
+ * ⇒ 取列表序第一条即用(同一模型,R29④——不排序、不比较、不告警)。 */
74
+ export function listedContextTokens(provider) {
75
+ const base = normalizeBaseURL(provider?.baseURL)
76
+ const target = normalizeModelKey(provider?.model)
77
+ if (!base || !target) return null
78
+ const bucket = listedByEndpoint.get(base)?.get(target)
79
+ return bucket ? positiveInt(bucket[0].tokens) : null
80
+ }
81
+
82
+ /** L1 读面:`providers[].modelContext[模型]` 人工声明(K 单位)。未声明 ∥ 值非法 ⇒ `null`
83
+ * (落下一层);两条键归一后相同 = 同一模型的两种写法 ⇒ 取对象键序第一条即用(R29④——
84
+ * 不排序、不比较、不告警)。值非法在装载期已告警一次(`config.mjs`),此处只判不告(热路径)。 */
85
+ export function manualContextK(provider) {
86
+ const table = provider?.modelContext
87
+ if (!table || typeof table !== "object" || Array.isArray(table)) return null
88
+ const target = normalizeModelKey(provider?.model)
89
+ if (!target) return null
90
+ for (const [key, value] of Object.entries(table)) if (normalizeModelKey(key) === target) return positiveInt(value)
91
+ return null
92
+ }
93
+
94
+ /** 测试缝:清空 L3 缓存 + 告警去重(单元档用;生产零调用)。 */
95
+ export function _resetModelContextForTest() {
96
+ listedByEndpoint.clear()
97
+ warned.clear()
98
+ }