@feng0531/dsh-code-review 0.86.0 → 0.86.1

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 CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  本文件介绍对使用者有意义的变化。安装与参数见 [README](README.md),本次发布正文见 [发布说明](RELEASE-NOTES.md)。历史提交和安装包保留在 [GitHub Releases](https://github.com/f-e-n-g-0531/dsh-code-review/releases);本文不把开发日志当作功能介绍。
4
4
 
5
+ ## 0.86.1 — 修复取证额度分配、重复读取与工件容量
6
+
7
+ - 同快照精确操作复用,重复交付仍扣字节并重新签发复核回执;根预算内为主审、综合及复核预留最低份额,释放闲置额度。
8
+ - 已校验组成员保留,组受限原因传播不重复计数;后续综合预算失败不改写已完成窗口成果。
9
+ - 工件默认数量上限64改为4096,单件1MiB/任务8MiB不变,超限仍明确标记。
10
+ - 终态摘要显示报告真实partial/覆盖与分页总量,跨文件综合不再硬编码误报超时;增加74文件规模化离线回归。
11
+
5
12
  ## 0.86.0 — 原生结构化输出、私有留存、断点续审与组主审
6
13
 
7
14
  - 优先使用真实宿主原生结果工具并保留同次文本兼容;只接受未改写的完整 JSON,歧义失败关闭,证据校验不放宽。
@@ -13,7 +13,8 @@
13
13
  | 结果传输 | 原生结果工具,同次文本兼容;仅原生模式接受完整 JSON 后明确尾注 | 修补损坏 JSON、猜字段/身份、无歧义解析所有输出 |
14
14
  | 显式续审 | 新预览通过完整快照、配置和路由身份核验后复用已完成合格文件链 | 恢复旧授权、变化后无条件复用、未完成阶段已覆盖 |
15
15
  | 私有模型工件 | 宿主任务目录外于业务工作区,有界保存文本/工具参数及失败工件,普通报告只给摘要 | 工件成为批准来源、失败草稿成为确认发现、Windows 模式位保证 ACL |
16
- | 问题复核 | 原文位置检查、模型反证与状态记录 | 缺陷必然成立、无漏报或无误报 |
16
+ | 取证资源 | 同快照精确操作复用、每次交付计字节、阶段最低额度及闲置份额释放 | 根预算增加、每阶段一定有新读额度、缓存即本阶段证据 |
17
+ | 问题复核 | 原文位置检查、模型反证与状态记录、每次重新签发回执 | 缺陷必然成立、无漏报或无误报 |
17
18
  | 文件指导 | 语言、协议、配置与模板的保守检查提示 | 编译器、类型检查器或真实运行结果 |
18
19
 
19
20
  ## 当前限制
package/README.md CHANGED
@@ -243,7 +243,7 @@ Git / SVN 历史审查也能使用这些参数;依赖和调用方按对应版
243
243
 
244
244
  需要补审时,在**新预览**中传入 `resumeFromJobId`,例如 `{"resumeFromJobId":"原执行任务的真实jobId"}`;仍须展示新范围、重新确认并通过当前宿主权限。只复用同一会话、工作目录、模型路由/思考设置下,完整快照、配置和运行时身份均匹配的已完成且符合复用条件的文件链;项目模式还绑定对应批次。来源或配置变化不做猜测复用。未完成阶段重新执行,旧发现标记 `priorRun`,不伪造本轮读取凭据、模型调用或覆盖。
245
245
 
246
- 后台审查会在上述宿主任务目录的 `artifacts` 子目录留存有界模型工件,包括失败/取消时已捕获的文本或工具参数;不含 reasoning。它们可能含源码,应按源码保护,不会自动加入提示词、当作已确认发现或在普通状态报告公开。默认单工件 1 MiB、每任务原文总量 8 MiB、最多 64 条记录;截断由工件标记,存储超限明确返回遗漏标记和无源码诊断。读取绑定原会话、目录、提供方、模型和思考设置。需要查看时,Agent 必须先提示“可能把源码/模型原文带入当前对话”,取得你的明确同意,再调用 `code_review_artifacts({jobId,confirmed:true})`;可用 `offset`/`limit` 分页,默认一次 1 条、最多 100 条。返回内容标为 `sourceBearing:true`、`untrusted:true`,不是批准证据;普通 `status` 不返回工件原文。目录/文件使用 0700/0600 权限模式及原子写入;Windows 模式位不等于额外 ACL 保证。
246
+ 后台审查会在上述宿主任务目录的 `artifacts` 子目录留存有界模型工件,包括失败/取消时已捕获的文本或工具参数;不含 reasoning。它们可能含源码,应按源码保护,不会自动加入提示词、当作已确认发现或在普通状态报告公开。默认单工件 1 MiB、每任务原文总量 8 MiB、最多 4096 条记录;截断由工件标记,存储超限明确返回遗漏标记和无源码诊断。读取绑定原会话、目录、提供方、模型和思考设置。需要查看时,Agent 必须先提示“可能把源码/模型原文带入当前对话”,取得你的明确同意,再调用 `code_review_artifacts({jobId,confirmed:true})`;可用 `offset`/`limit` 分页,默认一次 1 条、最多 100 条。返回内容标为 `sourceBearing:true`、`untrusted:true`,不是批准证据;普通 `status` 不返回工件原文。目录/文件使用 0700/0600 权限模式及原子写入;Windows 模式位不等于额外 ACL 保证。
247
247
 
248
248
  ## 多个会话同时审查
249
249
 
@@ -336,7 +336,7 @@ Git / SVN 历史审查也能使用这些参数;依赖和调用方按对应版
336
336
 
337
337
  模型搜索的精确命中可同轮附带批准来源原文,每阶段最多2次额外读取尝试、每次最多24行;占用原共享额度并预留4次操作/16KiB输出,不猜测另侧坐标,不增加检索轮数。配置 `focusPrimaryContext:false`、`enableSearchSeeds:false` 可分别关闭上下文聚焦和搜索命中附读;无检索能力时不会隐藏正文。
338
338
 
339
- 共享读取预算按本次可审文件数有界缩放,预览的 `execution.readerBudget` 显示具体值;所有阶段共用、不逐文件刷新,达到上限的必要证据阶段不再无效启动,并保留未完成范围。配置 `riskPlanningMode:"separate"`、`readerBudgetPolicy:"fixed"` 可回退原规划和固定读取额度,这些是插件配置,不是preview参数。更多额度不保证更快或完整覆盖;不建议用提高并发掩盖冗余调用。
339
+ 共享读取预算按本次可审文件数有界缩放,预览的 `execution.readerBudget` 显示具体值;所有阶段共用、不逐文件刷新。生产编排在根上限内按主审规模及综合/复核单元权重预留最低额度,未用份额释放;最低份额取整可能为零,不保证每个阶段都有新读额度。同一批准快照的相同操作结果可复用,`calls` 计实际结果生成、缓存命中另计;每次交付仍计序列化字节,复核回执重新签发,跨快照不复用。必要证据不足仍保留未完成范围,不以提前返回当作完成。配置 `riskPlanningMode:"separate"`、`readerBudgetPolicy:"fixed"` 可回退原规划和固定读取额度,这些是插件配置,不是preview参数。更多额度不保证更快或完整覆盖;不建议用提高并发掩盖冗余调用。
340
340
 
341
341
  诊断显示实际加载版本、阶段及调用目的计数、读取余量,并区分单阶段检索轮数和共享读取额度。新增无源码的JSON纠正原因、协议/审查格式纠正计数及未合并规划原因;纠正计数以实际纠正调用为准;这些诊断不含原响应、源码片段或提供方错误文本,与下面说明的私有模型工件分开。`primary-initial` 是检索链初始调用,不只包含主审阶段。累计调用耗时可能并发重叠,不能当作总耗时或纯提供方耗时。离线调用数减少不代表实际审核必然同比提速,不能用更快返回partial冒充审核完成。
342
342
 
package/RELEASE-NOTES.md CHANGED
@@ -1,27 +1,23 @@
1
- # v0.86.0 发布说明
1
+ # v0.86.1 发布说明
2
2
 
3
- 使用方式见 [README](README.md),历史变化见 [变更记录](CHANGELOG.md),验证与发布门禁见 [开发说明](DEVELOPMENT.md)。
3
+ 使用方式见 [README](README.md),历史变化见 [变更记录](CHANGELOG.md),发布门禁见 [开发说明](DEVELOPMENT.md)。
4
4
 
5
- ## 输出协议:减少格式往返,不放宽证据
5
+ ## 取证预算与成果保留
6
6
 
7
- 生产入口优先使用真实 DSH 原生结果工具 submit_review_result,结构化提交作为数据交给现有审查、归因、定位和复核校验器,不执行模型返回的工具。宿主支持的 ToolSchema、工具调用增量/最终块以及 tool-calls 结束标记均已对照安装宿主声明和组装器核查;不使用宿主不存在的强制 toolChoice 参数。
7
+ 生产编排为主审、跨窗口/跨文件综合及候选复核在同一个根读取预算内按权重预留最低额度,阶段结束释放闲置份额;不提高根调用或输出字节上限。最低份额取整可能为零,额度和检索轮数不足仍如实披露,不能保证所有来源或阶段完成。
8
8
 
9
- 提供方仍返回文本时,允许同一次调用的严格 JSON/单一围栏兼容;原生模式额外允许从响应开头提取未修改的完整 JSON 容器并忽略无歧义普通尾注,不搜索任意分析段、不选择多个对象、不拼补身份、字段或源码。歧义和非法工具调用失败关闭;有界格式纠正仍显式计调用。预览、窗口准备及检索、可选预取、复核使用原生工具定义、指令合同和正文的精确输入字节测量,发送前再次硬校验,不用隐藏保留量交换正文。
9
+ 同一批准快照内,相同读取/搜索/查找请求可复用原始操作结果,身份、版本侧、坐标、查询和上限均参与匹配。返回独立副本;每次交付仍扣序列化输出字节,复核回执重新签发,不继承其他阶段凭据。缓存和分配账本参与原子事务回滚;失败、取消、源身份变化不放宽边界。低层默认保持原语义,仅生产优化编排启用。
10
10
 
11
- ## 私有留存与重新确认后的续审
11
+ 已经校验的组成员不因后续读取准入失败被丢弃;组级受限原因传播至所有成员,但共享事件只计一次。跨窗口/跨文件阶段预算失败单独记录,不把已完成主审/窗口成果改写成主审失败;复用资格仍拒绝未完成综合、受限阶段或未复核候选。
12
12
 
13
- 后台任务在审查工作区外有界保存原始文本块/工具参数,包括失败和取消的输出,不保存 reasoning 块。默认单记录1MiB、单任务8MiB、最多64条;超限省略和截断明确记录,不宣称无限或完整捕获。引用与计数可以进入状态和脱敏诊断,原始输出不能自动成为新审查证据。读取 code_review_artifacts 必须获得源码隐私知情同意并显式 confirmed:true,绑定相同会话、工作区和模型路由,默认一次1条。Windows不把mode位宣称为额外ACL隔离。
13
+ ## 工件容量与终态呈现
14
14
 
15
- resumeFromJobId 重新捕获、预览并确认完全相同的快照、上下文/规则/需求、路由、运行时及执行配置,执行时仍复核并重新授权;旧批准永不恢复。只复用全部阶段及所属交互已完成、候选已完成独立复核的文件链,支持变更和项目批次。中断、窗口未完成、缺证据或未复核成果仍保留但不能冒充可复用完成链。旧发现标记 priorRun/reused,旧证据不是本轮新 receipt,旧调用不计入本轮。
15
+ 默认工件数量上限从64改为4096,单记录1MiB、单任务8MiB不变;真实数量/字节超限、截断仍明确记录,不宣称无限留存。普通诊断不含原文;取回仍需源码隐私知情同意。
16
16
 
17
- ## 真正组主审与编排
18
-
19
- 业务分组成为文件链调度前的独立阶段,不再先跑完整条首文件链。2至4个能完整装入输入预算且差异精确的变更文件,可以一次 group-primary 生成全部成员的主审结果,再按真实 fileId 与原始行号逐项严格校验、分发;不会仍为每个成员重复发起首轮主审。超预算、窗口/多批次、非精确差异、项目模式和多轮独立审查在发送前保持原路径。
20
-
21
- 组内风险问题可以合并到本次生成,缺失/无效计划和未读来源明确披露,不补发隐藏规划调用。组主审不冒充跨文件证据:独立交互阶段及候选复核保留,不能把不合格 interaction 降级成普通发现绕过校验。调用预算耗尽时保留已经生成并校验的成员结果,把后续未完成阶段标为 partial,不丢弃已付费主审成果。失败组不隐藏补发全体单文件调用。
17
+ 任务结束的状态摘要显示真实报告状态、覆盖、调用、读取余量及分页总量,明确后台 completed 不等于报告 complete;一次 terminal wait 仍返回整份报告。跨文件综合不再统一误写为超时,而显示已有原因及失败代码。
22
18
 
23
19
  ## 验证与边界
24
20
 
25
- 新增离线回归覆盖原生流、歧义拒绝、格式兼容零额外调用、精确输入边界、私有留存与跨身份拒绝、取消清理、部分完成续审、新确认/授权及变更失效、组主审覆盖与预算边界。独立源码审计发现的“组结果在额度边界被丢弃”已修复并有回归;取消与落盘失败同时出现时保留原取消身份。
21
+ 新增离线夹具覆盖74文件共享读取、早期贪婪请求与后续复核额度、组限制传播、已完成窗口保留、零新读额度下缓存交付与新回执、事务回滚及128个小工件全部留存。它们证明机制,不代表已重跑用户业务,也不构成真实模型速度、覆盖或质量基准。
26
22
 
27
- 这轮对齐的是上述核心机制,不等于上游全部功能或模型质量完全一致;没有重跑用户业务审查,不承诺实际耗时、召回或全面覆盖。正式发布仍必须通过冻结标签 Ubuntu/Windows × Node.js22/24 四矩阵,以及 Actions、npm、GitHub Release 原始包逐字节、校验清单和冻结发布说明核验。不会自动安装、升级或重启当前 DSH。
23
+ 正式发布须通过冻结标签 Ubuntu/Windows × Node.js22/24 四矩阵及 Actions、npm、GitHub Release 原始包逐字节、校验清单和冻结发布说明核验。不会自动安装、升级或重启当前 DSH。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feng0531/dsh-code-review",
3
- "version": "0.86.0",
3
+ "version": "0.86.1",
4
4
  "private": false,
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
@@ -15,10 +15,46 @@ const CANCEL_PARAMETERS = { type: 'object', additionalProperties: false, propert
15
15
  const WAIT_DESCRIPTION = 'Wait for review progress or terminal result without ending the user request. Prefer waitFor=terminal to ignore intermediate checkpoints; progress preserves checkpoint-by-checkpoint compatibility. Reuse the exact returned waitRevision as after; never guess increments or call status after every event. Timeout defaults to 60000ms and is bounded to 60000ms; changed=false means timeout, not completion or cancellation. Call again while queued/running. Preview completion requires confirmation; execute completion returns one final consolidated report.';
16
16
  const WAIT_PARAMETERS = { type: 'object', additionalProperties: false, properties: { jobId: { type: 'string' }, after: { type: 'integer', minimum: 0, description: 'Exact waitRevision returned by start/status/wait; do not increment it yourself.' }, waitFor: { type: 'string', enum: ['progress', 'terminal'], default: 'progress', description: 'Use terminal to wait through intermediate saves; progress wakes on each durable checkpoint.' }, timeoutMs: { type: 'integer', minimum: 1, maximum: 60000, default: 60000 } }, required: ['jobId'] };
17
17
 
18
+ const TERMINAL_STATUSES = ['completed', 'failed', 'cancelled', 'interrupted'];
19
+ const REPORT_STATUSES = ['completed', 'partial', 'failed', 'cancelled'];
20
+ const COVERAGE_FIELDS = ['completed', 'excluded', 'blocked', 'failed', 'cancelled', 'pending'];
21
+ const LIMIT_CODES = ['READER_CALL_BUDGET_EXCEEDED', 'READER_OUTPUT_BUDGET_EXCEEDED', 'RETRIEVAL_ROUND_BUDGET_EXCEEDED'];
22
+ const count = value => Number.isSafeInteger(value) && value >= 0 ? value : '不可用';
23
+ function terminalSummary(value) {
24
+ const report = value.resultSummary;
25
+ const lines = ['审查任务已结束:' + value.status, 'jobId: ' + value.jobId];
26
+ if (value.progress) lines.push('elapsedMs: ' + count(value.progress.elapsedMs));
27
+ if (report && REPORT_STATUSES.includes(report.status)) {
28
+ lines.push('报告状态:' + report.status + ';任务结束不等于所有审查通过。');
29
+ lines.push('文件覆盖:' + COVERAGE_FIELDS.map(key => key + ' ' + count(report.coverage?.[key])).join(';'));
30
+ lines.push('总文件:' + count(report.totalFiles) + ';总发现:' + count(report.totalFindings) + ';限制项:' + count(report.totalLimitations));
31
+ lines.push('模型调用:' + count(report.modelCalls ?? report.diagnostics?.modelCalls));
32
+ const d = report.diagnostics;
33
+ if (d?.readerBudget) {
34
+ const b = d.readerBudget;
35
+ lines.push('共享读取预算:调用 ' + count(b.calls) + '/' + count(b.maxCalls) + ',输出字节 ' + count(b.outputBytes) + '/' + count(b.maxOutputBytes) + ';剩余调用 ' + count(b.remainingCalls) + ',剩余字节 ' + count(b.remainingOutputBytes));
36
+ }
37
+ const reasons = Array.isArray(d?.readerLimitReasons) ? d.readerLimitReasons : [];
38
+ const bounded = LIMIT_CODES.flatMap(code => { const row = reasons.find(item => item?.code === code); return row ? [code + ' ' + count(row.calls)] : []; });
39
+ if (bounded.length) lines.push('读取限制原因:' + bounded.join(';'));
40
+ if (d?.readerLimits) lines.push('每阶段最大检索轮次:' + count(d.readerLimits.maxRounds));
41
+ if (d?.artifacts) lines.push('私有工件计数:留存 ' + count(d.artifacts.retained) + ',未留存 ' + count(d.artifacts.omitted) + '(不包含原始正文)');
42
+ if (value.resultPage) lines.push('当前页:文件 ' + (value.resultPage.files?.length ?? 0) + '/' + count(value.resultPage.totalFiles) + ',发现 ' + (value.resultPage.findings?.length ?? 0) + '/' + count(value.resultPage.totalFindings) + ';code_review_status 的 offset/limit 仅切换结果页。');
43
+ lines.push('完整报告:调用一次 code_review_wait(jobId 同上,waitFor=terminal)立即取得最终报告;无需继续轮询。');
44
+ } else if (report?.previewId) {
45
+ lines.push('预览已完成,尚未执行审查;请展示批准范围并取得用户确认后再执行。');
46
+ lines.push('预览文件总量:' + count(report.filePage?.total ?? report.files?.length) + ';计划最少调用:' + count(report.plan?.minimumCalls));
47
+ } else {
48
+ lines.push('未提供完整报告摘要;终态不会自动恢复或重新发送源码。请检查已保存检查点和未完成范围。');
49
+ }
50
+ return lines.join('\n');
51
+ }
52
+
18
53
  function respond(value) {
19
54
  if (value.finalMarkdown !== null && value.finalMarkdown !== undefined) {
20
55
  return { json: JSON.stringify(value), markdown: value.finalMarkdown };
21
56
  }
57
+ if (TERMINAL_STATUSES.includes(value.status)) return { json: JSON.stringify(value), markdown: terminalSummary(value) };
22
58
  const p = value.progress;
23
59
  if (p) {
24
60
  const progress = p.total === null
@@ -65,13 +65,17 @@ export function primarySeeds(scope, request, { maxInputBytes = 96 * 1024, onRetr
65
65
  if (attempted.size >= MAX_READS) break;
66
66
  if (seeds.some(item => item.request.id === source.id && item.result.start <= coordinate && coordinate < item.result.start + item.result.count)) continue;
67
67
  const budget = scope.budget();
68
- if (budget.remainingCalls <= RESERVE_CALLS || budget.remainingOutputBytes <= RESERVE_BYTES) break;
68
+ if (budget.remainingOutputBytes <= RESERVE_BYTES) break;
69
69
  const operation = { kind: 'read', id: source.id, start: Math.max(1, coordinate - 4), count: MAX_LINES };
70
70
  const key = JSON.stringify(operation);
71
71
  if (attempted.has(key)) continue;
72
72
  attempted.add(key);
73
73
  let item;
74
74
  try {
75
+ // A cached raw delivery costs no new materialization call, but still uses
76
+ // output bytes and must pass the exact operation's approved preflight.
77
+ const cached = budget.reusableResults === true && scope.preflight([operation])?.calls === 0 && scope.canServe?.([operation]) === true;
78
+ if (!cached && budget.remainingCalls <= RESERVE_CALLS) continue;
75
79
  item = scope.transaction(() => {
76
80
  checkSignal();
77
81
  scope.preflight([operation]);
@@ -79,7 +83,7 @@ export function primarySeeds(scope, request, { maxInputBytes = 96 * 1024, onRetr
79
83
  checkSignal();
80
84
  const next = retrievalRequest(request, scope, [...seeds, { request: operation, result }]);
81
85
  const remaining = scope.budget();
82
- if (result.start + result.count <= coordinate || remaining.remainingCalls < RESERVE_CALLS || remaining.remainingOutputBytes < RESERVE_BYTES || (request.nativeOutputTools === true ? !modelWireBudget(next, maxInputBytes).fits : Buffer.byteLength(next.input) + Buffer.byteLength(next.instructions) > maxInputBytes)) throw optionalError();
86
+ if (result.start + result.count <= coordinate || (!cached && remaining.remainingCalls < RESERVE_CALLS) || remaining.remainingOutputBytes < RESERVE_BYTES || (request.nativeOutputTools === true ? !modelWireBudget(next, maxInputBytes).fits : Buffer.byteLength(next.input) + Buffer.byteLength(next.instructions) > maxInputBytes)) throw optionalError();
83
87
  return { request: operation, result };
84
88
  });
85
89
  } catch (error) {
@@ -123,7 +123,7 @@ export function createProjectReviewService(llm, { allowModelSending = false, aut
123
123
  model: current.route, modelSendingEnabled: allowModelSending,
124
124
  totalTrackedNodes: s.totalTrackedNodes, selectedFiles: s.selectedFiles, selectionComplete: s.selectionComplete,
125
125
  inventoryComplete: s.inventoryComplete, inventoryScope: s.inventoryScope, inventoryNodes: s.inventoryNodes, batches: s.batches.length,
126
- privateArtifacts: { enabled: typeof exec.onModelArtifact === 'function', sourceBearing: true, maxArtifactBytes: 1048576, maxJobBytes: 8388608, maxArtifacts: 64, notice: 'Bounded private untrusted model outputs; raw access requires explicit consent and is not review evidence.' },
126
+ privateArtifacts: { enabled: typeof exec.onModelArtifact === 'function', sourceBearing: true, maxArtifactBytes: 1048576, maxJobBytes: 8388608, maxArtifacts: 4096, notice: 'Bounded private untrusted model outputs; raw access requires explicit consent and is not review evidence.' },
127
127
  execution: { nativeOutputTools, enableGroupPrimary, batchConcurrency: projectConcurrency, maxRetrievalRounds },
128
128
  files: s.batches.flatMap(b => b.files.map(f => ({ path: f.path, eligibility: f.eligibility, reason: f.reason }))),
129
129
  notice: s.notice + PREVIEW_NOTICE_SUFFIX + ' Each stage allows at most ' + maxRetrievalRounds + ' retrieval rounds; exhaustion requires a final-only response and explicit unfinished coverage.',
@@ -202,7 +202,7 @@ export function createProjectReviewService(llm, { allowModelSending = false, aut
202
202
  repositoryRoot: s.root, vcs: s.vcs, model: p.route, modelCalls: calls, maxModelCalls: projectMaxCalls,
203
203
  totalTrackedNodes: s.totalTrackedNodes, selectionComplete: s.selectionComplete, inventoryComplete: s.inventoryComplete, inventoryScope: s.inventoryScope, inventoryNodes: s.inventoryNodes,
204
204
  coverage, files, findings, artifacts: results.flatMap(result => result.artifacts ?? []), batches: results, resume: { reusedFiles: files.filter(file => file.reused).length },
205
- privateArtifacts: { enabled: typeof exec.onModelArtifact === 'function', sourceBearing: true, maxArtifactBytes: 1048576, maxJobBytes: 8388608, maxArtifacts: 64, notice: 'Bounded private untrusted model outputs; raw access requires explicit consent and is not review evidence.' },
205
+ privateArtifacts: { enabled: typeof exec.onModelArtifact === 'function', sourceBearing: true, maxArtifactBytes: 1048576, maxJobBytes: 8388608, maxArtifacts: 4096, notice: 'Bounded private untrusted model outputs; raw access requires explicit consent and is not review evidence.' },
206
206
  execution: { nativeOutputTools, enableGroupPrimary, batchConcurrency: projectConcurrency, maxRetrievalRounds },
207
207
  notice: s.notice + REPORT_NOTICE_SUFFIX,
208
208
  };
@@ -66,7 +66,7 @@ export async function retrievalLoop(model, request, scope, { correctProtocol = f
66
66
  };
67
67
  const checkExhausted = async round => {
68
68
  const budget = scope.budget?.();
69
- if (!finalization && budget && (budget.remainingCalls === 0 || budget.remainingOutputBytes === 0)) {
69
+ if (!finalization && budget && ((budget.remainingCalls === 0 && !budget.reusableResults) || budget.remainingOutputBytes === 0)) {
70
70
  const error = new Error(budget.remainingCalls === 0 ? 'Reader call budget exceeded' : 'Reader output budget exceeded');
71
71
  error.code = budget.remainingCalls === 0 ? 'READER_CALL_BUDGET_EXCEEDED' : 'READER_OUTPUT_BUDGET_EXCEEDED';
72
72
  await finalize(error);
@@ -40,7 +40,7 @@ async function projectedRealpath(name) {
40
40
  }
41
41
 
42
42
  // Artifacts are untrusted private evidence, never approved source or confirmed findings.
43
- export function createReviewArtifacts({ directory, maxArtifactBytes = 1048576, maxJobBytes = 8388608, maxArtifacts = 64 } = {}) {
43
+ export function createReviewArtifacts({ directory, maxArtifactBytes = 1048576, maxJobBytes = 8388608, maxArtifacts = 4096 } = {}) {
44
44
  if (typeof directory !== 'string' || !path.isAbsolute(directory)) throw fail('INVALID_REVIEW_ARTIFACT_DIRECTORY');
45
45
  if (![maxArtifactBytes, maxJobBytes, maxArtifacts].every(n => Number.isSafeInteger(n) && n > 0) || maxArtifactBytes > 16777216 || maxJobBytes > 268435456 || maxArtifacts > 10000) throw fail('INVALID_REVIEW_ARTIFACT_LIMIT');
46
46
  let canonical;
@@ -1,7 +1,7 @@
1
1
  import { REVIEW_RUNTIME } from './runtime-identity.mjs';
2
2
  const PHASES = ['business-grouping', 'risk-planning', 'primary', 'group-primary', 'file-interaction', 'window-synthesis', 'verification'];
3
3
  const PURPOSES = ['primary-initial', 'retrieval-followup', 'budget-finalization', 'json-correction', 'business-grouping', 'risk-planning', 'group-primary', 'window-synthesis', 'file-interaction', 'verification'];
4
- const READER_FIELDS = ['calls', 'outputBytes', 'maxCalls', 'maxOutputBytes', 'remainingCalls', 'remainingOutputBytes', 'maxResultBytes'];
4
+ const READER_FIELDS = ['calls', 'outputBytes', 'maxCalls', 'maxOutputBytes', 'remainingCalls', 'remainingOutputBytes', 'maxResultBytes', 'cacheHits', 'deliveries', 'cacheEntries', 'cacheBytes'];
5
5
  const JSON_ISSUES = ['property-name', 'colon', 'separator', 'invalid-escape', 'control-character', 'unterminated-string', 'unexpected-end', 'trailing-content', 'syntax'];
6
6
  const CORRECTION_CODES = ['MODEL_INVALID_JSON', 'MODEL_INVALID_REVIEW_RESPONSE', 'MODEL_INVALID_FINDING_IDENTITY', 'MODEL_INVALID_FINDING_SEVERITY', 'MODEL_MISSING_FINDING_FIELD', 'MODEL_INVALID_ANCHOR', 'MODEL_ANCHOR_MISMATCH', 'RETRIEVAL_PROTOCOL_ERROR'];
7
7
  const LIMIT_CODES = ['READER_CALL_BUDGET_EXCEEDED', 'READER_OUTPUT_BUDGET_EXCEEDED', 'RETRIEVAL_ROUND_BUDGET_EXCEEDED'];
@@ -67,6 +67,7 @@ export function diagnosticsMarkdown(d) {
67
67
  const n = field => number(d.readerBudget[field]) ?? '不可用';
68
68
  output.push('- 共享读取预算:调用 ' + n('calls') + '/' + n('maxCalls') + ',序列化输出字节 ' + n('outputBytes') + '/' + n('maxOutputBytes') + ';剩余调用 ' + n('remainingCalls') + ',剩余输出字节 ' + n('remainingOutputBytes') + '。');
69
69
  }
70
+ if (number(d.readerBudget?.cacheHits) !== null) output.push('- 快照内取证复用:缓存命中 ' + d.readerBudget.cacheHits + ',实际交付 ' + (number(d.readerBudget.deliveries) ?? '不可用') + ';calls 计唯一操作结果生成,重复交付仍计输出字节,复核回执每次重新签发。');
70
71
  if (d.correctionReasons?.length) output.push('- 纠正原因(仅实际纠正调用,不重复计失败/完成通知):' + d.correctionReasons.filter(r => CORRECTION_CODES.includes(r.code)).map(r => r.code + (JSON_ISSUES.includes(r.jsonIssue) ? '/' + r.jsonIssue : '') + ' ' + (number(r.calls) ?? '不可用')).join(';') + '。');
71
72
  if (d.readerLimitReasons?.length) output.push('- 读取限制原因:' + d.readerLimitReasons.filter(r => LIMIT_CODES.includes(r.code)).map(r => r.code + ' ' + (number(r.calls) ?? '不可用')).join(';') + '。');
72
73
  if (number(d.readerLimits?.maxRounds) !== null) output.push('- 每阶段最大检索轮次:' + number(d.readerLimits.maxRounds) + ';调用数/输出字节耗尽与轮次耗尽分别记录,不能混同。');
@@ -78,7 +78,7 @@ export function createReviewJobs({directory}={}){
78
78
  },
79
79
  async resume(id,exec){const r=await owned(id,exec);if(jobs.get(id)&&!jobs.get(id).settled||['queued','running'].includes(r.status)||!r.resumeState)throw new Error('No terminal completed-chain resume state available');return structuredClone(r.resumeState);},
80
80
  async artifacts(id,exec,page){const r=await owned(id,exec);return artifacts.read(id,r.owner,page);},
81
- async status(id,exec,page){const r=structuredClone(await owned(id,exec));delete r.resumeState;const active=jobs.get(id);if(active&&!active.settled&&!['queued','running'].includes(r.status)){r.status='running';r.stage='finalizing';delete r.result;delete r.finishedAt;}r.recovery=recoveryCoverage(r);r.progress=jobProgress(r);r.waitRevision=jobs.get(id)?.savedRevision??r.checkpoints.length;r.nextAction=['queued','running'].includes(r.status)?'Call code_review_wait with waitFor=terminal; reuse the exact returned waitRevision as after, never guess increments. No code_review_status call is needed after each event. Do not end the user request while task runs.':r.result?.previewId?'Show preview and request user confirmation before execute.':'Collect final report and unfinished scope, then provide one final answer.';if(!page)return r;const {offset=0,limit=20,checkpointIndex}=page;if(!Number.isSafeInteger(offset)||offset<0||!Number.isSafeInteger(limit)||limit<1||limit>100)throw new Error('Invalid result page');if(checkpointIndex!==undefined){if(!Number.isSafeInteger(checkpointIndex)||checkpointIndex<0||checkpointIndex>=r.checkpoints.length)throw new Error('Invalid checkpoint index');return {jobId:r.jobId,status:r.status,waitRevision:r.waitRevision,progress:r.progress,nextAction:r.nextAction,checkpointIndex,checkpoint:pageCheckpoint(r.checkpoints[checkpointIndex],offset,limit),page:{offset,limit}};}const result=r.result;const report=result?.report;return {...r,result:undefined,recovery:{...r.recovery,unfinishedStages:r.recovery.unfinishedStages.slice(offset,offset+limit),files:r.recovery.files.slice(offset,offset+limit),windows:r.recovery.windows.slice(offset,offset+limit),suggestedPaths:r.recovery.suggestedPaths.slice(offset,offset+limit),totalObservedFiles:r.recovery.files.length},checkpoints:r.checkpoints.slice(offset,offset+limit).map(c=>pageCheckpoint(c,0,limit)),checkpointPage:{offset,limit,total:r.checkpoints.length},...(result?{resultSummary:report?{status:report.status,coverage:report.coverage,model:report.model,snapshotId:report.snapshotId,diagnostics:report.diagnostics,execution:report.execution,modelCalls:report.modelCalls}:({...result,files:result.files?.slice(offset,offset+limit),filePage:result.files?{offset,limit,total:result.files.length}:undefined}),resultPage:report?{files:report.files?.slice(offset,offset+limit),findings:report.findings?.slice(offset,offset+limit),totalFiles:report.files?.length,totalFindings:report.findings?.length}:undefined}: {})};},
81
+ async status(id,exec,page){const r=structuredClone(await owned(id,exec));delete r.resumeState;const active=jobs.get(id);if(active&&!active.settled&&!['queued','running'].includes(r.status)){r.status='running';r.stage='finalizing';delete r.result;delete r.finishedAt;}r.recovery=recoveryCoverage(r);r.progress=jobProgress(r);r.waitRevision=jobs.get(id)?.savedRevision??r.checkpoints.length;r.nextAction=['queued','running'].includes(r.status)?'Call code_review_wait with waitFor=terminal; reuse the exact returned waitRevision as after, never guess increments. No code_review_status call is needed after each event. Do not end the user request while task runs.':r.result?.previewId?'Show preview and request user confirmation before execute.':'Collect final report and unfinished scope, then provide one final answer.';if(!page)return r;const {offset=0,limit=20,checkpointIndex}=page;if(!Number.isSafeInteger(offset)||offset<0||!Number.isSafeInteger(limit)||limit<1||limit>100)throw new Error('Invalid result page');if(checkpointIndex!==undefined){if(!Number.isSafeInteger(checkpointIndex)||checkpointIndex<0||checkpointIndex>=r.checkpoints.length)throw new Error('Invalid checkpoint index');return {jobId:r.jobId,status:r.status,waitRevision:r.waitRevision,progress:r.progress,nextAction:r.nextAction,checkpointIndex,checkpoint:pageCheckpoint(r.checkpoints[checkpointIndex],offset,limit),page:{offset,limit}};}const result=r.result;const report=result?.report;return {...r,result:undefined,recovery:{...r.recovery,unfinishedStages:r.recovery.unfinishedStages.slice(offset,offset+limit),files:r.recovery.files.slice(offset,offset+limit),windows:r.recovery.windows.slice(offset,offset+limit),suggestedPaths:r.recovery.suggestedPaths.slice(offset,offset+limit),totalObservedFiles:r.recovery.files.length},checkpoints:r.checkpoints.slice(offset,offset+limit).map(c=>pageCheckpoint(c,0,limit)),checkpointPage:{offset,limit,total:r.checkpoints.length},...(result?{resultSummary:report?{status:report.status,coverage:report.coverage,model:report.model,snapshotId:report.snapshotId,diagnostics:report.diagnostics,execution:report.execution,modelCalls:report.modelCalls,totalFiles:report.files?.length,totalFindings:report.findings?.length,totalLimitations:report.limitations?.length}:({...result,files:result.files?.slice(offset,offset+limit),filePage:result.files?{offset,limit,total:result.files.length}:undefined}),resultPage:report?{files:report.files?.slice(offset,offset+limit),findings:report.findings?.slice(offset,offset+limit),totalFiles:report.files?.length,totalFindings:report.findings?.length}:undefined}: {})};},
82
82
  async wait(id,exec,{after,waitFor='progress',timeoutMs=60000,signal}={}){
83
83
  const r=await owned(id,exec);
84
84
  if(!Number.isSafeInteger(timeoutMs)||timeoutMs<1||timeoutMs>60000)throw new Error('Invalid wait duration');
@@ -0,0 +1,75 @@
1
+ // Per-stage admission over ONE approved root ledger, not independent readers.
2
+ // Fixed proportional floors protect future stages; released unused floors become
3
+ // shared capacity. No content/source identifiers are emitted by summary().
4
+ export function createReviewReaderAllocation(root, units) {
5
+ if (!root || !['budget', 'usage', 'preflight', 'transaction', 'read', 'find', 'search', 'catalog'].every(key => typeof root[key] === 'function')) throw new Error('Invalid root reader allocation');
6
+ if (!Array.isArray(units) || units.length > 2000) throw new Error('Invalid reader allocation units');
7
+ const budget = root.budget();
8
+ if (![budget.remainingCalls, budget.remainingOutputBytes].every(value => Number.isSafeInteger(value) && value >= 0)) throw new Error('Invalid root reader allocation budget');
9
+ const states = new Map(); let totalWeight = 0;
10
+ for (const unit of units) {
11
+ if (!unit || typeof unit.id !== 'string' || !unit.id || unit.id.length > 500 || states.has(unit.id) || !Number.isSafeInteger(unit.weight) || unit.weight < 1 || unit.weight > 1000000) throw new Error('Invalid reader allocation unit');
12
+ states.set(unit.id, { weight: unit.weight, calls: 0, outputBytes: 0, cacheHits: 0, released: false, inTransaction: false });
13
+ totalWeight += unit.weight;
14
+ }
15
+ for (const state of states.values()) {
16
+ state.callFloor = Math.floor(budget.remainingCalls * state.weight / totalWeight);
17
+ state.byteFloor = Math.floor(budget.remainingOutputBytes * state.weight / totalWeight);
18
+ }
19
+ const fail = kind => { const error = new Error('Reader allocation ' + kind + ' budget exceeded'); error.code = kind === 'call' ? 'READER_CALL_BUDGET_EXCEEDED' : 'READER_OUTPUT_BUDGET_EXCEEDED'; throw error; };
20
+ const live = id => { const state = states.get(id); if (!state || state.released) throw new Error('Reader allocation unit unavailable'); return state; };
21
+ const available = state => {
22
+ const remaining = root.budget(); let protectedCalls = 0, protectedBytes = 0;
23
+ for (const other of states.values()) {
24
+ if (other === state || other.released) continue;
25
+ protectedCalls += Math.max(0, other.callFloor - other.calls);
26
+ protectedBytes += Math.max(0, other.byteFloor - other.outputBytes);
27
+ }
28
+ return { ...remaining, remainingCalls: Math.max(0, remaining.remainingCalls - protectedCalls), remainingOutputBytes: Math.max(0, remaining.remainingOutputBytes - protectedBytes) };
29
+ };
30
+ const check = (state, cost) => { const remaining = available(state); if (cost.calls > remaining.remainingCalls) fail('call'); if (cost.outputBytes > remaining.remainingOutputBytes) fail('output'); };
31
+ const scopes = new Map();
32
+ return Object.freeze({
33
+ scope(id) {
34
+ const state = live(id); if (scopes.has(id)) return scopes.get(id);
35
+ const preflight = requests => { live(id); const cost = root.preflight(requests); if (!cost || !Number.isSafeInteger(cost.calls) || !Number.isSafeInteger(cost.outputBytes)) throw new Error('Reader allocation preflight unavailable'); check(state, cost); return cost; };
36
+ const execute = operation => {
37
+ live(id); const before = root.usage(), remaining = available(state);
38
+ const result = operation(), after = root.usage();
39
+ const calls = after.calls - before.calls, outputBytes = after.outputBytes - before.outputBytes;
40
+ if (calls > remaining.remainingCalls) fail('call');
41
+ if (outputBytes > remaining.remainingOutputBytes) fail('output');
42
+ state.calls += calls; state.outputBytes += outputBytes; state.cacheHits += (after.cacheHits ?? 0) - (before.cacheHits ?? 0);
43
+ return result;
44
+ };
45
+ const operation = (kind, request, decorate) => {
46
+ preflight([{ ...request, kind }]);
47
+ const run = () => execute(() => kind === 'read' ? root.read(request, decorate) : root[kind](request));
48
+ if (state.inTransaction) return run();
49
+ const before = { calls: state.calls, outputBytes: state.outputBytes, cacheHits: state.cacheHits };
50
+ try { return root.transaction(run); } catch (error) { Object.assign(state, before); throw error; }
51
+ };
52
+ const scope = Object.freeze({
53
+ catalog: () => { live(id); return root.catalog(); },
54
+ budget: () => { live(id); return available(state); },
55
+ usage: () => ({ calls: state.calls, outputBytes: state.outputBytes, cacheHits: state.cacheHits }),
56
+ preflight,
57
+ canServe(requests) { try { preflight(requests); return true; } catch { return false; } },
58
+ read: (request, decorate) => operation('read', request, decorate),
59
+ find: request => operation('find', request),
60
+ search: request => operation('search', request),
61
+ transaction(executeBatch) {
62
+ live(id); if (state.inTransaction || typeof executeBatch !== 'function') throw new Error('Invalid reader allocation transaction');
63
+ const before = { calls: state.calls, outputBytes: state.outputBytes, cacheHits: state.cacheHits };
64
+ state.inTransaction = true;
65
+ try { return root.transaction(executeBatch); }
66
+ catch (error) { Object.assign(state, before); throw error; }
67
+ finally { state.inTransaction = false; }
68
+ },
69
+ });
70
+ scopes.set(id, scope); return scope;
71
+ },
72
+ release(id) { const state = states.get(id); if (!state) return; if (state.inTransaction) throw new Error('Reader allocation transaction active'); state.released = true; },
73
+ summary() { const usage = root.usage(); return { units: states.size, releasedUnits: [...states.values()].filter(state => state.released).length, calls: usage.calls, outputBytes: usage.outputBytes, ...(usage.cacheHits !== undefined ? { cacheHits: usage.cacheHits } : {}) }; },
74
+ });
75
+ }
@@ -36,7 +36,7 @@ export function readerAdmission(prepared, remaining) {
36
36
  if (!required) return { permitted: true };
37
37
  const calls = remaining?.remainingCalls, bytes = remaining?.remainingOutputBytes;
38
38
  if (!Number.isSafeInteger(calls) || !Number.isSafeInteger(bytes)) return { permitted: false, code: 'READER_BUDGET_UNAVAILABLE' };
39
- if (calls <= 0) return { permitted: false, code: 'READER_CALL_BUDGET_EXCEEDED' };
39
+ if (calls <= 0 && !remaining.reusableResults) return { permitted: false, code: 'READER_CALL_BUDGET_EXCEEDED' };
40
40
  if (bytes <= 0) return { permitted: false, code: 'READER_OUTPUT_BUDGET_EXCEEDED' };
41
41
  return { permitted: true };
42
42
  }
package/src/review.mjs CHANGED
@@ -29,6 +29,7 @@ import { changesConcurrency, retrievalRounds } from './review-execution-policy.m
29
29
  import { scheduleFileChains } from './file-chain-scheduler.mjs';
30
30
  import { reviewReaderBudget, readerAdmission } from './review-reader-policy.mjs';
31
31
  import { primarySeeds } from './primary-seeds.mjs';
32
+ import { createReviewReaderAllocation } from './review-reader-allocation.mjs';
32
33
  import { prepareGroupPrimary, validateGroupPrimary } from './group-primary.mjs';
33
34
  import { configureWireBudget, modelWireBudget } from './model-wire-budget.mjs';
34
35
 
@@ -124,7 +125,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
124
125
  if (options.enableBusinessGrouping === true && options.enableRetrieval !== true) throw new Error('Business grouping requires approved retrieval scope');
125
126
  if (options.enableVerification === true && options.enableRetrieval !== true) throw new Error('Verification requires approved retrieval scope');
126
127
  const readerLimits = reviewReaderBudget(input, options.readerBudgetPolicy ?? 'fixed');
127
- const scope = options.enableRetrieval === true ? createRetrievalScope({ ...input, context: input.context ?? [] }, { ...readerLimits, signal }) : null;
128
+ const scope = options.enableRetrieval === true ? createRetrievalScope({ ...input, context: input.context ?? [] }, { ...readerLimits, signal, ...(options.optimizeReviewCalls === true ? { reuseResults: true } : {}) }) : null;
128
129
  const report = { schemaVersion: 1, snapshotId: input.id, vcs: input.vcs, status: 'completed', files: [], findings: [], limitations: [], modelCalls: 0 };
129
130
  validateBusinessRequirement(input.businessRequirement);
130
131
  if(input.businessRequirement!==undefined)report.businessRequirement={hash:hash(input.businessRequirement),bytes:Buffer.byteLength(input.businessRequirement),status:'untrusted-constraint'};
@@ -174,20 +175,24 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
174
175
  } });
175
176
  };
176
177
  const groupPrepared = new Map(), groupResults = new Map(), groupStarted = new Map();
178
+ let readerAllocation;
179
+ const stageScope = id => readerAllocation
180
+ ? configureWireBudget(readerAllocation.scope(id), { nativeOutputTools: options.nativeOutputTools === true }) : scope;
181
+ const releaseStage = id => readerAllocation?.release(id);
177
182
  const groupResult = async group => {
178
183
  if (!groupResults.has(group.id)) {
179
184
  groupStarted.set(group.id, performance.now());
180
185
  groupResults.set(group.id, invoke(async request => {
181
- const prepared = groupPrepared.get(group.id); let limited = false; const reads = [];
182
- const response = await retrievalLoop(sharedModel, { ...request, nativeOutputTools: options.nativeOutputTools === true, input: prepared.payload, instructions: prepared.instructions, reviewPhase: 'group-primary' }, scope, {
186
+ const prepared = groupPrepared.get(group.id), groupScope = stageScope('group:' + group.id); const limits = [], reads = [];
187
+ const response = await retrievalLoop(sharedModel, { ...request, nativeOutputTools: options.nativeOutputTools === true, input: prepared.payload, instructions: prepared.instructions, reviewPhase: 'group-primary' }, groupScope, {
183
188
  maxRounds: maxRetrievalRounds, maxInputBytes, beforeCall: reserveShared, correctProtocol: options.correctProtocol === true,
184
189
  enableSearchSeeds: options.enableSearchSeeds === true,
185
190
  onRetrieved: records => { reads.push(...records); (sharedState.retrievalAudit ??= []).push(...records); },
186
- onBudgetLimited: info => { limited = true; (sharedState.retrievalLimits ??= []).push(info); },
191
+ onBudgetLimited: info => { limits.push(info); (sharedState.retrievalLimits ??= []).push(info); },
187
192
  });
188
193
  const results = validateGroupPrimary(response, prepared, input, validateResponse, scope.catalog());
189
194
  for (const generated of results.values()) {
190
- generated.groupLimited = limited;
195
+ generated.groupLimits = structuredClone(limits);
191
196
  if (generated.groupRisk) {
192
197
  generated.groupRisk.sourceReadCoverage = riskReadCoverage(generated.groupRisk.risks, reads, scope.catalog(), input.id);
193
198
  if (generated.groupRisk.status !== 'completed') generated.limitations.push('组主审的合并风险计划缺失或无效,未补发规划调用;不表示已完成风险条件核查');
@@ -197,7 +202,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
197
202
  return results;
198
203
  }, { input: groupPrepared.get(group.id).payload, instructions: groupPrepared.get(group.id).instructions }, timeoutMs, signal));
199
204
  }
200
- return groupResults.get(group.id);
205
+ try { return await groupResults.get(group.id); } finally { releaseStage('group:' + group.id); }
201
206
  };
202
207
  const runFile = async index => {
203
208
  const file = input.files[index];
@@ -229,14 +234,16 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
229
234
  if(changes.precision==='envelope')result.limitations.push({fileId:file.id,text:'精确差异不可得,按保守包络分段检查;包络包含可能未变的行,不证明精确编辑或回归因果。'});
230
235
  if (changes.status === 'limited') result.limitations.push({ fileId: file.id, text: '精确变更分析受限:' + changes.reason + ';仍按完整两侧内容审查' });
231
236
  if (state.groupFallback) result.limitations.push({ fileId: file.id, text: '关联组上下文超过输入预算,回退单文件审查' });
232
- if (state.initialInputBytes > maxInputBytes) { state.status = 'blocked'; state.reason = '输入超过预算,未截断或提交模型'; return; }
237
+ if (state.initialInputBytes > maxInputBytes) { state.status = 'blocked'; state.reason = '输入超过预算,未截断或提交模型'; releaseStage('primary:' + file.id); releaseStage('synthesis:' + file.id); releaseStage('verification:' + file.id); return; }
233
238
  try {
234
239
  const beforeCall = ({ reserved = false } = {}) => {
235
240
  if (!reserved && options.remainingCalls?.() <= 0) throw Object.assign(new Error('Shared project model call limit exceeded'), { code: 'PROJECT_MODEL_CALL_LIMIT' });
236
241
  if (report.modelCalls >= maxCalls) throw new Error('Model call budget exceeded');
237
242
  report.modelCalls++;
238
243
  };
244
+ let activeReader = scope, activeStage = 'primary';
239
245
  const onBudgetLimited = info => {
246
+ if (activeStage !== 'primary') (state.secondaryRetrievalLimits ??= []).push({ phase: activeStage, ...info });
240
247
  state.retrievalLimited = true;
241
248
  (state.retrievalLimits ??= []).push(info);
242
249
  result.limitations.push({ fileId: file.id, text: '检索预算受限,已保留现有结果但不能视为完整审查:' + info.reason });
@@ -277,7 +284,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
277
284
  });
278
285
  };
279
286
  const executor = scope
280
- ? (request, onLimited = () => {}) => retrievalLoop(trackedModel, { ...request, nativeOutputTools: options.nativeOutputTools === true, onProtocolEvent }, scope, { correctProtocol:options.correctProtocol===true,maxRounds:maxRetrievalRounds,maxInputBytes, beforeCall, initialRetrieved: request.initialRetrieved ?? [], allowExhaustedInitial: request.allowExhaustedInitial === true, enableSearchSeeds: options.enableSearchSeeds === true && !request.reviewPhase?.includes('verification'), onBudgetLimited: info => { onBudgetLimited(info); onLimited(info); }, onRetrieved: records => {
287
+ ? (request, onLimited = () => {}) => retrievalLoop(trackedModel, { ...request, nativeOutputTools: options.nativeOutputTools === true, onProtocolEvent }, activeReader, { correctProtocol:options.correctProtocol===true,maxRounds:maxRetrievalRounds,maxInputBytes, beforeCall, initialRetrieved: request.initialRetrieved ?? [], allowExhaustedInitial: request.allowExhaustedInitial === true, enableSearchSeeds: options.enableSearchSeeds === true && !request.reviewPhase?.includes('verification'), onBudgetLimited: info => { onBudgetLimited(info); onLimited(info); }, onRetrieved: records => {
281
288
  (state.retrievalAudit ??= []).push(...records);
282
289
  }, onTruncated: () => {
283
290
  if (!state.retrievalTruncated) result.limitations.push({ fileId: file.id, text: '检索结果达到数量上限,部分匹配未提供给模型' });
@@ -288,7 +295,11 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
288
295
  const groupedGeneration = grouped ? (await groupResult({ id: grouped[0] })).get(file.id) : null;
289
296
  if (grouped) {
290
297
  state.primaryGroupId = grouped[0]; state.primaryMethod = 'group-primary';
291
- if (groupedGeneration.groupLimited) state.retrievalLimited = true;
298
+ if (groupedGeneration.groupLimits?.length) {
299
+ state.retrievalLimited = true;
300
+ state.groupRetrievalLimits = structuredClone(groupedGeneration.groupLimits);
301
+ result.limitations.push(...state.groupRetrievalLimits.map(limit => ({ fileId: file.id, text: '组主审检索受限:' + limit.code + ';已接受的本成员成果保留,不表示完整审查' })));
302
+ }
292
303
  if (groupedGeneration.groupRisk) (state.riskPlans ??= []).push({ ...groupedGeneration.groupRisk, method: 'inline', scope: 'group-all-edits', groupId: grouped[0], decision: { required: true, reason: 'group-inline-primary' } });
293
304
  }
294
305
  const chainTimeout = grouped && timeoutMs > 0 ? Math.max(0, timeoutMs - (performance.now() - groupStarted.get(grouped[0]))) : timeoutMs;
@@ -306,9 +317,14 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
306
317
  enclosingRequest.signal.throwIfAborted();
307
318
  const phase=batch.interaction?'file-interaction':batch.synthesis?'window-synthesis':'primary';
308
319
  try{
309
- const admission = scope && options.optimizeReviewCalls === true ? readerAdmission({ synthesis: batch.synthesis === true, interaction: !!batch.interaction, metadata: prepared.metadata }, scope.budget()) : { permitted: true };
320
+ activeStage = phase;
321
+ activeReader = groupedGeneration && !batch.synthesis && !batch.interaction ? scope : stageScope(batch.interaction ? 'interaction:' + batch.interaction.id : batch.synthesis ? 'synthesis:' + file.id : 'primary:' + file.id);
322
+ // Consuming an already validated group member performs no new source/model work.
323
+ const cachedPrimary = groupedGeneration && !batch.synthesis && !batch.interaction;
324
+ const admission = !cachedPrimary && activeReader && options.optimizeReviewCalls === true
325
+ ? readerAdmission({ synthesis: batch.synthesis === true, interaction: !!batch.interaction, metadata: prepared.metadata }, activeReader.budget()) : { permitted: true };
310
326
  if (!admission.permitted) {
311
- state.readerAdmission = { code: admission.code, remaining: scope.budget() };
327
+ state.readerAdmission = { code: admission.code, remaining: activeReader.budget() };
312
328
  if (batch.synthesis || batch.interaction) throw Object.assign(new Error('Required evidence budget unavailable before stage admission'), { code: admission.code });
313
329
  state.reason = '检索预算不足以取得本阶段所需证据,未启动模型;需重新预览补审';
314
330
  state.failureCode = admission.code;
@@ -372,7 +388,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
372
388
  const generate = async attempt => {
373
389
  if (!initialInputBudget(attempt, scope, maxInputBytes).fits) throw new Error('Review correction input budget exceeded');
374
390
  const initialRetrieved = scope && options.enablePrimarySeeds === true && maxRetrievalRounds > 0 && !batch.synthesis && !batch.interaction
375
- ? primarySeeds(scope, attempt, { maxInputBytes, signal: attempt.signal }) : [];
391
+ ? primarySeeds(activeReader, attempt, { maxInputBytes, signal: attempt.signal }) : [];
376
392
  return executor({ ...attempt, initialRetrieved }, () => { finalResponseOnly = true; });
377
393
  };
378
394
  const validate = response => {
@@ -430,10 +446,12 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
430
446
  result.findings.push(...generated.findings);
431
447
  if (generated.findings.some(f => f.attribution.status === 'missing')) result.limitations.push({ fileId: file.id, text: '部分发现缺少变更归因,仅校验了定位,尚不能确认属于本次回归' });
432
448
  result.limitations.push(...generated.limitations.map(text => ({ fileId: file.id, text })));
449
+ if (!groupedGeneration && !scheduled.slice(batchIndex + 1).some(next => !next.synthesis && !next.interaction)) releaseStage('primary:' + file.id);
433
450
  if (options.enableVerification === true && generated.findings.length) {
434
451
  for (const finding of generated.findings) finding.verification = { status: 'pending', causality: 'unverified' };
435
452
  try {
436
- const verdicts = await verificationLoop(trackedModel, { ...request, onProtocolEvent, reviewPhase: 'verification' }, generated.findings, scope, {
453
+ activeStage = 'verification';
454
+ const verdicts = await verificationLoop(trackedModel, { ...request, onProtocolEvent, reviewPhase: 'verification' }, generated.findings, stageScope('verification:' + file.id), {
437
455
  correctProtocol:options.correctProtocol===true,maxRounds:maxRetrievalRounds,maxInputBytes, beforeCall, onBudgetLimited,
438
456
  onRetrieved: records => { (state.retrievalAudit ??= []).push(...records); },
439
457
  onTruncated: () => { result.limitations.push({ fileId: file.id, text: '复核检索结果达到数量上限' }); },
@@ -463,7 +481,11 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
463
481
  await options.onProgress?.({stage:'window-complete',snapshotId:input.id,fileId:file.id,path:file.path,windowIds:batch.windowIds,round:batch.round,findings:generated.findings,windowCoverage:state.windowCoverage});
464
482
  }catch(error){
465
483
  enclosingRequest.signal.throwIfAborted();
466
- if(phase==='primary'||error.code==='MODEL_ARTIFACT_FAILED'||(/budget exceeded/i.test(error.message)&&!groupedGeneration))throw error;
484
+ if(phase==='primary'||error.code==='MODEL_ARTIFACT_FAILED')throw error;
485
+ if (['Model call budget exceeded', 'Model call budget exceeded before next review pass'].includes(error.message) || error.code === 'PROJECT_MODEL_CALL_LIMIT') {
486
+ result.limitations.push({ fileId: file.id, text: phase + '模型调用额度不足,尚未启动的阶段保留 pending;已完成主审成果保留' });
487
+ break;
488
+ }
467
489
  const failure={phase,status:'failed',code:error.code??'REVIEW_EXECUTION_FAILED',reason:error.message,round:batch.round,...(error.attempts?{attempts:error.attempts}:{}),...(error.diagnostic?{diagnostic:error.diagnostic}:{})};
468
490
  (state.stageFailures??=[]).push(failure);
469
491
  if(batch.synthesis)state.synthesisStatus='failed';
@@ -478,10 +500,10 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
478
500
 
479
501
  state.status = state.retrievalLimited ? 'pending' : 'completed';
480
502
  if (state.retrievalLimited) {
481
- const codes = new Set((state.retrievalLimits ?? []).map(limit => limit.code));
503
+ const codes = new Set([...(state.retrievalLimits ?? []), ...(state.groupRetrievalLimits ?? [])].map(limit => limit.code));
482
504
  state.reason = codes.has('RETRIEVAL_ROUND_BUDGET_EXCEEDED')
483
- ? '单阶段检索轮数上限达到,未完成完整审查;共享读取额度可能仍有余量,已有发现保留,不表示通过'
484
- : '共享读取调用或输出预算受限,未完成完整审查;已有发现保留,不表示通过';
505
+ ? '单阶段检索轮数上限达到,未完成完整审查;读取额度可能仍有余量,已有发现保留,不表示通过'
506
+ : (readerAllocation ? '阶段取证分配或共享读取预算受限,未完成完整审查;其他阶段保留额度不表示本阶段已完成,已有发现保留' : '共享读取调用或输出预算受限,未完成完整审查;已有发现保留,不表示通过');
485
507
  if (codes.size === 1) state.failureCode = [...codes][0];
486
508
  }
487
509
 
@@ -496,6 +518,12 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
496
518
  state.failureCode = signal?.aborted ? 'CANCELLED' : ['MODEL_INVALID_JSON','MODEL_INCOMPLETE_OUTPUT','MODEL_TIMEOUT','MODEL_INVALID_READ_REQUEST','MODEL_OUTPUT_LIMIT','MODEL_PROTOCOL_ERROR','MODEL_INVALID_REVIEW_RESPONSE','MODEL_INVALID_FINDING_IDENTITY','MODEL_INVALID_FINDING_SEVERITY','MODEL_MISSING_FINDING_FIELD','MODEL_INVALID_ANCHOR','MODEL_ANCHOR_MISMATCH','READER_CALL_BUDGET_EXCEEDED','READER_OUTPUT_BUDGET_EXCEEDED','RETRIEVAL_ROUND_BUDGET_EXCEEDED','RETRIEVAL_FINAL_RESPONSE_REQUIRED','PROJECT_MODEL_CALL_LIMIT'].includes(error?.code) ? error.code : 'REVIEW_EXECUTION_FAILED';
497
519
  }
498
520
  } finally {
521
+ if (readerAllocation && file.eligibility === 'reviewable' && !options.resumeState?.completed?.some(row => row.fileId === file.id)) {
522
+ if (![...groupPrepared.values()].some(group => group.fileIds.includes(file.id))) releaseStage('primary:' + file.id);
523
+ releaseStage('verification:' + file.id);
524
+ releaseStage('synthesis:' + file.id);
525
+ for (const plan of interactionPlans.filter(plan => input.files.filter(member => plan.group.fileIds.includes(member.id)).at(-1)?.id === file.id)) releaseStage('interaction:' + plan.group.id);
526
+ }
499
527
  state.resumeEligible = state.status === 'completed' && !state.stageFailures?.length && !state.reviewPasses?.some(pass => pass.status !== 'completed') && !state.windowCoverage?.pending?.length && !state.retrievalLimited && result.findings.every(f => f.verification?.status === 'completed') && report.interactions.filter(item => item.fileIds?.includes(file.id)).every(item => item.status === 'completed');
500
528
  await options.onProgress?.({stage:'file-complete',snapshotId:input.id,file:state,findings:result.findings});
501
529
  await options.onResumeCheckpoint?.({ ...report, files: fileResults.filter(Boolean).map(item => item.state), findings: fileResults.filter(Boolean).flatMap(item => item.findings), limitations: [...report.limitations, ...fileResults.filter(Boolean).flatMap(item => item.limitations)] });
@@ -540,6 +568,19 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
540
568
  }
541
569
  report.primaryGroups = [...groupPrepared].map(([groupId, p]) => ({ groupId, fileIds: p.fileIds, mode: 'group-primary' }));
542
570
  }
571
+ if (scope && options.optimizeReviewCalls === true) {
572
+ const units = [];
573
+ const reusable = new Set((options.resumeState?.completed ?? []).map(row => row.fileId));
574
+ for (const [id, prepared] of groupPrepared) units.push({ id: 'group:' + id, weight: prepared.fileIds.length * 4 });
575
+ for (const file of input.files.filter(file => file.eligibility === 'reviewable' && !reusable.has(file.id))) {
576
+ const prepared = preparePrimaryInput(input, file, { instructions, scope, grouping: report.grouping, maxInputBytes, riskPlanningMode: options.riskPlanningMode, focusPrimaryContext: options.focusPrimaryContext === true, enableRiskPlanning: options.enableRiskPlanning, skipSmallRiskPlans: options.skipSmallRiskPlans });
577
+ if (![...groupPrepared.values()].some(group => group.fileIds.includes(file.id))) units.push({ id: 'primary:' + file.id, weight: 4 * Math.max(1, prepared.batches?.length ?? 1) });
578
+ if (prepared.synthesis?.status === 'ready') units.push({ id: 'synthesis:' + file.id, weight: 2 });
579
+ if (options.enableVerification === true) units.push({ id: 'verification:' + file.id, weight: 2 });
580
+ }
581
+ for (const plan of interactionPlans.filter(plan => plan.prepared.status === 'ready' && !plan.group.fileIds.every(id => reusable.has(id)))) units.push({ id: 'interaction:' + plan.group.id, weight: 2 });
582
+ if (units.length) readerAllocation = createReviewReaderAllocation(scope, units);
583
+ }
543
584
  const dependencies = new Map();
544
585
  for (const plan of interactionPlans.filter(p => p.prepared.status === 'ready')) {
545
586
  const members = input.files.map((file, index) => plan.group.fileIds.includes(file.id) ? index : -1).filter(index => index >= 0);
@@ -576,6 +617,7 @@ export async function reviewSnapshot(snapshot, model, options = {}) {
576
617
  await options.onResumeCheckpoint?.(report);
577
618
  report.followup = coverageFollowup(report);
578
619
  if (scope) report.readerBudget = { ...scope.usage(), ...scope.budget(), ...readerLimits };
620
+ if (readerAllocation) report.readerAllocation = readerAllocation.summary();
579
621
  report.diagnostics = reviewDiagnostics(report, { reviewWallMs: performance.now() - startedAt });
580
622
  return report;
581
623
  }
@@ -640,7 +682,7 @@ export function markdownReport(report) {
640
682
  }
641
683
  for (const file of report.files) if (file.windowCoverage) output.push('- ' + safe(file.path) + ':变更窗口已完成 ' + file.windowCoverage.completed.length + ',待审 ' + file.windowCoverage.pending.length + ';仅表示窗口分析完成,不证明全文正确');
642
684
  for (const file of report.files) if (file.synthesisStatus) output.push('- ' + safe(file.path) + ':跨窗口综合 ' + safe(file.synthesisStatus) + ';不等于事实正确性证明');
643
- for (const interaction of report.interactions ?? []) output.push('- 跨文件综合 ' + safe(interaction.groupId) + ':' + safe(interaction.status) + ';共享组末主文件超时,不证明完整交互覆盖');
685
+ for (const interaction of report.interactions ?? []) output.push('- 跨文件综合 ' + safe(interaction.groupId) + ':' + safe(interaction.status) + (interaction.failureCode ? ' · ' + safe(interaction.failureCode) : '') + (interaction.reason ? ' — ' + safe(interaction.reason) : '') + ';与组末主文件共享生命周期,不证明完整交互覆盖');
644
686
  if (report.rules?.length) output.push('', '## 项目规则', '规则仅为已批准约束数据,不扩大权限或证明缺陷。', ...report.rules.map(rule => '- ' + safe(rule.path) + ' · SHA256 ' + safe(rule.hash)));
645
687
  if (report.followup?.suggestedPaths.length) output.push('', '## 后续选择建议', '仅为原快照未完成项;不会自动续审。再次执行需新预览、新审批,并显式选择规则和上下文。阻断项需先处理原因;关联上下文不等于主文件已审查。', ...report.followup.items.filter(item => item.followup).map(item => '- ' + safe(item.path) + ':' + item.status + (item.incompleteVerification ? '(候选复核未完成)' : '')));
646
688
  output.push('', '## 审查发现');
package/src/service.mjs CHANGED
@@ -97,7 +97,7 @@ function createSessionReviewService(llm, { capture = captureSnapshot, now = Date
97
97
  if (owner(exec).cwd !== current.cwd) throw new Error('Agent directory changed; preview again');
98
98
  const resumeState = savedResume === undefined ? undefined : validateResumeState(savedResume, snapshot, resumeOptions(options, current.route, rounds));
99
99
  const readerBudget = reviewReaderBudget(snapshot, readerBudgetPolicy);
100
- const plan = buildCoveragePlan(snapshot, { enableGroupPrimary, resumeState, focusPrimaryContext, riskPlanningMode, enableRiskPlanning, skipSmallRiskPlans, reviewRounds:rounds, enableBusinessGrouping, instructions: REVIEW_INSTRUCTIONS, scope: configureWireBudget(createRetrievalScope(snapshot, { ...readerBudget, signal: exec.signal }), { nativeOutputTools }), grouping: buildReviewGroups(snapshot.files, inferFileRelations(snapshot.files)), maxInputBytes: 96 * 1024, signal: exec.signal });
100
+ const plan = buildCoveragePlan(snapshot, { enableGroupPrimary, resumeState, focusPrimaryContext, riskPlanningMode, enableRiskPlanning, skipSmallRiskPlans, reviewRounds:rounds, enableBusinessGrouping, instructions: REVIEW_INSTRUCTIONS, scope: configureWireBudget(createRetrievalScope(snapshot, { ...readerBudget, signal: exec.signal, ...(optimizeReviewCalls ? { reuseResults: true } : {}) }), { nativeOutputTools }), grouping: buildReviewGroups(snapshot.files, inferFileRelations(snapshot.files)), maxInputBytes: 96 * 1024, signal: exec.signal });
101
101
  prune();
102
102
  while (previews.size >= maxPreviews) previews.delete(previews.keys().next().value);
103
103
  const previewId = randomUUID();
@@ -110,7 +110,7 @@ function createSessionReviewService(llm, { capture = captureSnapshot, now = Date
110
110
  ...(resumeState ? { resume: resumePreview(resumeState, sourceJobId) } : {}),
111
111
  plan,
112
112
  snapshotId: snapshot.id,
113
- privateArtifacts: { enabled: typeof exec.onModelArtifact === 'function', sourceBearing: true, maxArtifactBytes: 1048576, maxJobBytes: 8388608, maxArtifacts: 64, notice: 'Background jobs retain bounded untrusted model output outside the reviewed workspace; omissions/truncation explicit. Never included in ordinary status or reused as evidence; explicit consent required to read.' },
113
+ privateArtifacts: { enabled: typeof exec.onModelArtifact === 'function', sourceBearing: true, maxArtifactBytes: 1048576, maxJobBytes: 8388608, maxArtifacts: 4096, notice: 'Background jobs retain bounded untrusted model output outside the reviewed workspace; omissions/truncation explicit. Never included in ordinary status or reused as evidence; explicit consent required to read.' },
114
114
  execution: { nativeOutputTools, enableGroupPrimary, fileConcurrency: changesConcurrency, maxRetrievalRounds, ...(optimizeReviewCalls ? { riskPlanningMode, readerBudget: { policy: readerBudgetPolicy, ...readerBudget }, missingContextSeeds: enablePrimarySeeds, focusPrimaryContext, searchContextSeeds: enableSearchSeeds } : {}) },
115
115
  repositoryRoot: snapshot.root,
116
116
  vcs: snapshot.vcs,
@@ -141,7 +141,7 @@ function createSessionReviewService(llm, { capture = captureSnapshot, now = Date
141
141
  ...(snapshot.callerDiscovery ? { callerDiscovery: snapshot.callerDiscovery } : {}),
142
142
  ...(snapshot.autoContext ? { autoContext: snapshot.autoContext } : {}),
143
143
  rules: (snapshot.rules ?? []).map(({ path, hash }) => ({ path, hash })),
144
- notice: buildPreviewNotice(snapshot, options, enableRiskPlanning, changesConcurrency, maxRetrievalRounds) + (optimizeReviewCalls ? ` 完整正文或覆盖全部精确编辑的单批窗口可将风险问题合并入主审(非独立规划);仅对批准上下文的确定坐标预取小片段,不代表完整核查。${focusPrimaryContext ? " 主审优先保留主文件全文,仅附带有关联的有界完整上下文;其余批准来源保留目录按需读,不表示不存在或已核查。" : ""}${enableSearchSeeds ? " 可将模型搜索命中的批准原行小片段与搜索结果同轮提供,每阶段最多2次额外读取,共用原预算;不猜测另侧坐标,不增加检索轮次。" : ""}共享检索上限按本次范围计算:${readerBudget.maxCalls}次操作/${readerBudget.maxOutputBytes}字节,所有阶段共用、不逐文件重置;无法取得必要证据的阶段不再无效启动。` : ''),
144
+ notice: buildPreviewNotice(snapshot, options, enableRiskPlanning, changesConcurrency, maxRetrievalRounds) + (optimizeReviewCalls ? ` 完整正文或覆盖全部精确编辑的单批窗口可将风险问题合并入主审(非独立规划);仅对批准上下文的确定坐标预取小片段,不代表完整核查。${focusPrimaryContext ? " 主审优先保留主文件全文,仅附带有关联的有界完整上下文;其余批准来源保留目录按需读,不表示不存在或已核查。" : ""}${enableSearchSeeds ? " 可将模型搜索命中的批准原行小片段与搜索结果同轮提供,每阶段最多2次额外读取,共用原预算;不猜测另侧坐标,不增加检索轮次。" : ""}共享检索上限按本次范围计算:${readerBudget.maxCalls}次唯一操作结果生成/${readerBudget.maxOutputBytes}交付字节,所有阶段共用、不逐文件重置;同快照相同请求可复用原始结果,每次交付仍计字节并重新签发复核回执;主审、综合及复核按权重预留最低额度,未用额度释放,额度不足仍如实标记未完成。` : ''),
145
145
  };
146
146
  } finally { busy = false; }
147
147
  },
@@ -21,15 +21,17 @@ function budgetError(kind) {
21
21
  }
22
22
 
23
23
  // This capability reads only caller-approved text entries, never the filesystem.
24
- export function createSnapshotReader(snapshotId, entries, { maxBytes = 4 * 1024 * 1024, maxCalls = 50, maxOutputBytes = 256 * 1024, signal } = {}) {
24
+ export function createSnapshotReader(snapshotId, entries, { maxBytes = 4 * 1024 * 1024, maxCalls = 50, maxOutputBytes = 256 * 1024, signal, reuseResults = false } = {}) {
25
25
  if (typeof snapshotId !== 'string' || !snapshotId || snapshotId.length > 200) throw new Error('Invalid snapshot identity');
26
26
  if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 16 * 1024 * 1024) throw new Error('Invalid reader budget');
27
27
  if (!Array.isArray(entries) || entries.length > 500) throw new Error('Invalid reader entries');
28
28
  for (const [value, cap] of [[maxCalls, 1000], [maxOutputBytes, 4 * 1024 * 1024]]) {
29
29
  if (!Number.isSafeInteger(value) || value < 1 || value > cap) throw new Error('Invalid reader budget');
30
30
  }
31
+ if (typeof reuseResults !== 'boolean') throw new Error('Invalid reader reuse option');
31
32
  signal?.throwIfAborted();
32
33
 
34
+ let cache = new Map(), cacheBytes = 0, cacheHits = 0, deliveries = 0;
33
35
  let calls = 0, outputBytes = 0, inTransaction = false;
34
36
  const begin = () => {
35
37
  signal?.throwIfAborted();
@@ -103,16 +105,66 @@ export function createSnapshotReader(snapshotId, entries, { maxBytes = 4 * 1024
103
105
  return { snapshotId, sourceId: id, hash: source.hash, start, count: selectedCount, text, endOfSource: endOffset === source.text.length };
104
106
  };
105
107
 
108
+ const validate = (kind, request) => {
109
+ if (!request || typeof request !== 'object') throw new Error('Invalid reader request');
110
+ if (kind === 'read') {
111
+ if (!sources.has(request.id)) throw new Error('Source not authorized');
112
+ if (!Number.isSafeInteger(request.start) || request.start < 1 || !Number.isSafeInteger(request.count) || request.count < 1 || request.count > 200) throw new Error('Invalid line range');
113
+ } else {
114
+ if (!validQuery(request.query, request.limit === undefined ? 20 : request.limit)) throw new Error('Invalid query request');
115
+ if (kind === 'search' && request.sourceIds !== undefined && (!Array.isArray(request.sourceIds) || !request.sourceIds.length || request.sourceIds.length > 20 || new Set(request.sourceIds).size !== request.sourceIds.length || request.sourceIds.some(id => !sources.has(id)))) throw new Error('Invalid search source filter');
116
+ }
117
+ };
118
+ const keyOf = (kind, request) => JSON.stringify(kind === 'read' ? [kind, request.id, request.start, request.count] : kind === 'find' ? [kind, request.query, request.limit ?? 20] : [kind, request.query, request.limit ?? 20, request.sourceIds === undefined ? null : [...request.sourceIds].sort()]);
119
+ const materialize = (kind, request) => kind === 'read' ? read(request) : kind === 'find' ? find(request) : search(request);
120
+ const deliver = (kind, request, decorate = value => value) => {
121
+ signal?.throwIfAborted();
122
+ // Validation is never bypassed by canonicalization (e.g. duplicate filters).
123
+ if (reuseResults) validate(kind, request);
124
+ const key = reuseResults ? keyOf(kind, request) : null;
125
+ const cached = reuseResults && cache.get(key);
126
+ if (!cached) begin();
127
+ const raw = cached ? structuredClone(cached.result) : materialize(kind, request);
128
+ const result = finish(decorate(structuredClone(raw)));
129
+ if (reuseResults) {
130
+ deliveries++;
131
+ if (cached) cacheHits++;
132
+ else {
133
+ const size = resultSize(raw);
134
+ if (cache.size < maxCalls && cacheBytes + size <= maxOutputBytes) { cache.set(key, { result: structuredClone(raw), size }); cacheBytes += size; }
135
+ }
136
+ }
137
+ return result;
138
+ };
139
+ const preflight = requests => {
140
+ signal?.throwIfAborted();
141
+ if (!Array.isArray(requests) || !requests.length || requests.length > 8) throw new Error('Invalid retrieval batch');
142
+ const staged = new Map(cache); let stagedBytes = cacheBytes, needed = 0, size = 0, hits = 0;
143
+ for (const request of requests) {
144
+ if (!['read', 'find', 'search'].includes(request?.kind)) throw new Error('Unsupported retrieval operation');
145
+ // Dry-run validates against immutable approved sources; it grants no receipts.
146
+ validate(request.kind, request);
147
+ const key = reuseResults ? keyOf(request.kind, request) : null;
148
+ const raw = reuseResults && staged.has(key) ? staged.get(key).result : materialize(request.kind, request), bytes = resultSize(raw);
149
+ if (reuseResults && staged.has(key)) hits++;
150
+ else { needed++; if (reuseResults && staged.size < maxCalls && stagedBytes + bytes <= maxOutputBytes) { staged.set(key, { result: raw, size: bytes }); stagedBytes += bytes; } }
151
+ size += bytes;
152
+ }
153
+ if (calls + needed > maxCalls) throw budgetError('call');
154
+ if (outputBytes + size > maxOutputBytes) throw budgetError('output');
155
+ return { calls: needed, outputBytes: size, cacheHits: hits };
156
+ };
106
157
  return Object.freeze({
107
- usage: () => ({ calls, outputBytes }),
108
- budget: () => ({ maxCalls, maxOutputBytes, remainingCalls: maxCalls - calls, remainingOutputBytes: maxOutputBytes - outputBytes, maxResultBytes: 64 * 1024 }),
158
+ usage: () => ({ calls, outputBytes, ...(reuseResults ? { cacheHits, deliveries, cacheEntries: cache.size, cacheBytes } : {}) }),
159
+ budget: () => ({ maxCalls, maxOutputBytes, remainingCalls: maxCalls - calls, remainingOutputBytes: maxOutputBytes - outputBytes, maxResultBytes: 64 * 1024, ...(reuseResults ? { reusableResults: cache.size > 0 } : {}) }),
160
+ canServe(requests) { try { preflight(requests); return true; } catch { return false; } },
109
161
  // A synchronous host-only batch commits its ledger only as a whole. This
110
162
  // also covers decorated response bytes that preflight cannot predict.
111
163
  // Callers must not publish results/audit records before this returns.
112
164
  transaction(execute) {
113
165
  signal?.throwIfAborted();
114
166
  if (inTransaction || typeof execute !== 'function') throw new Error('Invalid reader transaction');
115
- const previousCalls = calls, previousBytes = outputBytes;
167
+ const previousCalls = calls, previousBytes = outputBytes, previousCache = new Map(cache), previousCacheBytes = cacheBytes, previousHits = cacheHits, previousDeliveries = deliveries;
116
168
  inTransaction = true;
117
169
  try {
118
170
  const result = execute();
@@ -120,29 +172,17 @@ export function createSnapshotReader(snapshotId, entries, { maxBytes = 4 * 1024
120
172
  signal?.throwIfAborted();
121
173
  return result;
122
174
  } catch (error) {
123
- calls = previousCalls; outputBytes = previousBytes;
175
+ calls = previousCalls; outputBytes = previousBytes; cache = previousCache; cacheBytes = previousCacheBytes; cacheHits = previousHits; deliveries = previousDeliveries;
124
176
  throw error;
125
177
  } finally { inTransaction = false; }
126
178
  },
127
179
  // Host-only dry run. Validate ALL ranges and sources before charging anything.
128
180
  // No evidence or receipts escape this preflight. Decorated reads are still
129
181
  // measured by finish; this never reserves or increases the shared budget.
130
- preflight(requests) {
131
- signal?.throwIfAborted();
132
- if (!Array.isArray(requests) || !requests.length || requests.length > 8) throw new Error('Invalid retrieval batch');
133
- const results = requests.map(request => {
134
- if (request?.kind === 'read') return read(request);
135
- if (request?.kind === 'find') return find(request);
136
- if (request?.kind === 'search') return search(request);
137
- throw new Error('Unsupported retrieval operation');
138
- });
139
- if (calls + requests.length > maxCalls) throw budgetError('call');
140
- const size = results.reduce((sum, result) => sum + resultSize(result), 0);
141
- if (outputBytes + size > maxOutputBytes) throw budgetError('output');
142
- },
143
- find(request) { begin(); return finish(find(request)); },
144
- search(request) { begin(); return finish(search(request)); },
145
- // decorate is a host-only callback; model requests never supply it.
146
- read(request, decorate = value => value) { begin(); return finish(decorate(read(request))); },
182
+ preflight,
183
+ find(request) { return deliver('find', request); },
184
+ search(request) { return deliver('search', request); },
185
+ // Only immutable raw text is cached; each delivery creates fresh decorations.
186
+ read(request, decorate) { return deliver('read', request, decorate); },
147
187
  });
148
188
  }
@@ -35,8 +35,10 @@ export function verificationSeeds(candidates, scope, { signal, fits, onReceipt =
35
35
  const budget = scope.budget?.();
36
36
  // At least one exact final validation read per candidate must remain. This
37
37
  // reserves no new authority; additional evidence can still exhaust budgets.
38
- if (budget && (budget.remainingCalls <= candidates.length || budget.remainingOutputBytes <= finalEvidenceBytes)) break;
38
+ if (budget && budget.remainingOutputBytes <= finalEvidenceBytes) break;
39
39
  try {
40
+ const cached = budget?.reusableResults === true && scope.preflight?.([request])?.calls === 0 && scope.canServe?.([request]) === true;
41
+ if (budget && !cached && budget.remainingCalls <= candidates.length) continue;
40
42
  // Failed transactions discard their receipt table too, not just the ledger.
41
43
  const receipts = createEvidenceReceipts(scope.read, { limit: 1 });
42
44
  const item = scope.transaction(() => {
@@ -48,7 +50,22 @@ export function verificationSeeds(candidates, scope, { signal, fits, onReceipt =
48
50
  const proposed = [...seeds, { request, result }];
49
51
  const nextBudget = scope.budget?.();
50
52
  const reserve = finalEvidenceBytes + Buffer.byteLength(JSON.stringify(result)) * references;
53
+ if (cached) {
54
+ // Validation reads the actual returned count, not the seed's requested
55
+ // EOF-clamped count. These are different exact cache keys. Reserve all
56
+ // final reads explicitly before admitting a zero-call cached seed.
57
+ let calls = 0;
58
+ for (const item of proposed) {
59
+ const validation = { kind: 'read', id: item.result.sourceId, start: item.result.start, count: item.result.count };
60
+ const cost = scope.preflight([validation]);
61
+ if (!Number.isSafeInteger(cost?.calls) || cost.calls < 0) throw capacityError();
62
+ const matching = [...ranges.values()].find(range => JSON.stringify(range.request) === JSON.stringify(item.request));
63
+ calls += cost.calls * (matching?.references ?? 1);
64
+ }
65
+ if (nextBudget && calls > nextBudget.remainingCalls) throw capacityError();
66
+ }
51
67
  if (Buffer.byteLength(JSON.stringify(proposed)) > MAX_SEED_BYTES || !fits(proposed) || (nextBudget && nextBudget.remainingOutputBytes < reserve)) throw capacityError();
68
+ signal?.throwIfAborted();
52
69
  return { request, result };
53
70
  });
54
71
  // Publish only after the approved reader has committed the transaction.