dsh-codex-approval 0.3.0 → 0.4.0

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/README.md CHANGED
@@ -126,6 +126,8 @@ dsh plugin --profile web add dsh-codex-approval
126
126
  fallback: ask # 无规则命中且 AI 关闭时:ask | deny | allow
127
127
  denyFeedback: true # 拒绝后向主 agent 注入归因更正消息(默认 true)
128
128
  denyFeedbackMax: 3 # 未注入拒绝队列上限(1-10)
129
+ transcript: off # off | short:AI 审判是否带紧凑会话上下文(默认 off)
130
+ transcriptMaxChars: 4000 # 上下文骨架字符上限(100-16000)
129
131
  logFile: ~/.dsh/logs/approval.jsonl
130
132
  ```
131
133
 
@@ -141,10 +143,29 @@ dsh plugin --profile web add dsh-codex-approval
141
143
 
142
144
  ## AI 审判输入/输出
143
145
 
144
- **输入**:固定系统提示(审批员角色 + risk/authorization 定义 + 只输出 JSON 约束)+ `{"toolName", "command", "reason"}`(命令截断 2000 字符,无其他上下文)。
146
+ **输入**:固定系统提示(审批员角色 + risk/authorization 定义 + **意图优先规则** + 只输出 JSON 约束)+ `{"toolName", "command", "reason"}`(命令截断 2000 字符)。
147
+
148
+ 开启 `transcript: "short"` 后追加 **Context 块**(紧凑会话骨架,≤`transcriptMaxChars` 字符)——两级窗口:短窗口(最近用户消息 + ≤3 条工具调用 → `[U]/[T]/[R]` 行)+ 长窗口(更早的真实用户消息意图线)+ 模式行 `[M]` + 最近拒绝 `[D]` + 工作区 `[W]`。超长消息头尾保留 + 省略计数(`…〔省略 N 字符〕…`);plugin 注入消息与流式 chunk 一律不进骨架。**默认 off 时行为与 v0.3.0 完全一致。**
145
149
 
146
150
  **输出**:`{"risk":"low|medium|high","authorization":"allow|ask|deny","reason":"一句话"}`;解析策略:整体 JSON → ```json``` 代码块 → 平衡花括号扫描;枚举校验失败按 AI 故障处理。
147
151
 
152
+ ## 会话上下文(transcript,v0.4.0)
153
+
154
+ `transcript: "off"`(默认)= 零上下文判定(仅命令本体);`transcript: "short"` = AI 审判带紧凑上下文,可判断"用户明确要求的操作应放行"(意图优先)。实测口径成本:
155
+
156
+ | 组成 | off | short |
157
+ |---|---|---|
158
+ | 系统提示 | ~380 token | ~420 token |
159
+ | 上下文骨架(≤4000 字符) | — | ~1,600-1,800 token |
160
+ | 请求体(实测) | ~100-200 token | 同左 |
161
+ | **单次合计** | **~500 token** | **~2,100-2,400 token(≈4 倍)** |
162
+
163
+ 50 次审批的一天会话:off ≈ 25k token,short ≈ 110k token(前缀缓存命中后 ~84k)。绝对量由 `transcriptMaxChars` 硬闸封顶;缓存前缀(系统+模式+长窗口)占比 ~60-70%。实测本会话(5733 事件、含 12891 字符粘贴输出)骨架化后稳定在预算内。
164
+
165
+ ## PowerShell(Windows)规则
166
+
167
+ 默认规则含 `Bash(...)`(Linux/树莓派 toolName=bash 生效)与 `Pwsh(...)`(Windows toolName=pwsh 生效)两族**并存**——工具名大小写不敏感匹配,互不干扰:Windows 上只读命令(git status/diff/log、Get-ChildItem/ls、Get-Content/cat、Get-Location/pwd、Get-Command、Write-Output、Select-Object)自动放行;树莓派继续走 Bash 规则。如需整体替换规则,`cordis.patch.yml` 配 `rules` 即可(覆盖默认)。
168
+
148
169
  ## 安全注意事项
149
170
 
150
171
  - **deny 规则永远最先求值**,AI 无权覆盖显式拒绝
package/index.js CHANGED
@@ -30,6 +30,7 @@ import z from "@deepseek-ai/schemastery";
30
30
  import { evaluateRules } from "./rules.js";
31
31
  import { findToolCallArgs, argsPreview } from "./enrich.js";
32
32
  import { judgeWith, decideAuthorization } from "./judge.js";
33
+ import { buildTranscript } from "./transcript.js";
33
34
  import { MODES, parseMode, resolveMode, effectiveOnAsk } from "./modes.js";
34
35
  import { T, pickLocale, commandDescription, renderDenialNotice } from "./i18n.js";
35
36
 
@@ -75,7 +76,25 @@ export const DEFAULT_CONFIG = {
75
76
  // publishing: never auto-decided — a human must confirm every publish
76
77
  // (both bare `npm publish` and prefixed forms like `cd x && npm publish`)
77
78
  { match: "Bash(npm publish*)", action: "ask" },
78
- { match: "Bash(*npm publish*)", action: "ask" }
79
+ { match: "Bash(*npm publish*)", action: "ask" },
80
+ // PowerShell (Windows) counterparts for the read-only allow family:
81
+ // dsh's shell tool is `pwsh` on Windows, so the Bash(...) rules above
82
+ // never match there and every request went to the AI judge. These
83
+ // Pwsh(...) rules match only pwsh tool calls (tool names are
84
+ // case-insensitive); the Bash rules stay effective on Linux/Raspberry
85
+ // Pi, where the tool is `bash`. Both families coexist in this array.
86
+ { match: "Pwsh(git status*)", action: "allow" },
87
+ { match: "Pwsh(git diff*)", action: "allow" },
88
+ { match: "Pwsh(git log*)", action: "allow" },
89
+ { match: "Pwsh(Get-ChildItem *)", action: "allow" },
90
+ { match: "Pwsh(ls *)", action: "allow" },
91
+ { match: "Pwsh(Get-Content *)", action: "allow" },
92
+ { match: "Pwsh(cat *)", action: "allow" },
93
+ { match: "Pwsh(Get-Location)", action: "allow" },
94
+ { match: "Pwsh(pwd)", action: "allow" },
95
+ { match: "Pwsh(Get-Command *)", action: "allow" },
96
+ { match: "Pwsh(Write-Output *)", action: "allow" },
97
+ { match: "Pwsh(Select-Object *)", action: "allow" }
79
98
  ],
80
99
  ai: {
81
100
  enabled: true,
@@ -96,6 +115,12 @@ export const DEFAULT_CONFIG = {
96
115
  denyFeedback: true,
97
116
  // Pending-denial queue cap per session: older entries are dropped first.
98
117
  denyFeedbackMax: 3,
118
+ // Compact session transcript for the AI judge: "off" (default) keeps the
119
+ // v0.3.0 zero-context input; "short" adds a bounded two-level window
120
+ // skeleton (see transcript.js) so the judge sees user intent and the
121
+ // surrounding tool chain. Absolute size is capped by transcriptMaxChars.
122
+ transcript: "off",
123
+ transcriptMaxChars: 4000,
99
124
  logFile: join(homedir(), ".dsh", "logs", "approval.jsonl")
100
125
  };
101
126
 
@@ -122,6 +147,10 @@ function assertConfig(cfg) {
122
147
  if (!Number.isSafeInteger(cfg.denyFeedbackMax) || cfg.denyFeedbackMax < 1 || cfg.denyFeedbackMax > 10) {
123
148
  throw new TypeError("dsh-codex-approval: config.denyFeedbackMax must be an integer in 1..10");
124
149
  }
150
+ if (!["off", "short"].includes(cfg.transcript)) throw new TypeError("dsh-codex-approval: config.transcript must be off/short");
151
+ if (!Number.isSafeInteger(cfg.transcriptMaxChars) || cfg.transcriptMaxChars < 100 || cfg.transcriptMaxChars > 16000) {
152
+ throw new TypeError("dsh-codex-approval: config.transcriptMaxChars must be an integer in 100..16000");
153
+ }
125
154
  if (typeof cfg.logFile !== "string" || cfg.logFile === "") throw new TypeError("dsh-codex-approval: config.logFile must be a non-empty path");
126
155
  }
127
156
 
@@ -145,13 +174,17 @@ function outcomeFor(action) {
145
174
 
146
175
  /** The real LLM runner: ctx.llm.prepareCall + stream, bounded by timeout. */
147
176
  export function makeLlmRunner(llm, { provider, model, timeoutMs, maxTokens }) {
148
- return async (messages, { signal } = {}) => {
177
+ return async (messages, { signal, sessionId } = {}) => {
149
178
  const timeoutSignal = AbortSignal.timeout(timeoutMs);
150
179
  const combined = signal !== undefined ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
151
180
  try {
152
181
  const prepared = await llm.prepareCall({ provider, model, temperature: 0, maxTokens }, combined);
153
182
  let text = "";
154
- for await (const chunk of prepared.stream({ ...prepared.config, messages })) {
183
+ for await (const chunk of prepared.stream({
184
+ ...prepared.config,
185
+ messages,
186
+ ...sessionId === undefined ? {} : { sessionId }
187
+ })) {
155
188
  if (chunk.type === "text-delta") text += chunk.text;
156
189
  else if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
157
190
  return { ok: false, error: `judge stream finished with ${chunk.reason.kind}` };
@@ -167,21 +200,30 @@ export function makeLlmRunner(llm, { provider, model, timeoutMs, maxTokens }) {
167
200
  /**
168
201
  * Create the approval/request handler with injected dependencies
169
202
  * (unit-testable without a cordis ctx).
170
- * @param deps - { config, record, llmRunner, getSessionMode, denialFeed }
203
+ * @param deps - { config, record, llmRunner, getSessionMode, denialFeed, denialHistory, getCwd }
171
204
  * `denialFeed` is an optional Map<sessionId, Array<DenialRecord>> used to
172
205
  * stage plugin-originated denials for the `agent/pre-step` injector; when
173
206
  * omitted the handler creates its own (shared only if the caller passes it).
207
+ * `denialHistory` is an optional Map<sessionId, Array<DenialRecord>>
208
+ * accumulating the last few denials of each session for the transcript
209
+ * context ([D] lines) — created internally when omitted.
210
+ * `getCwd` optionally returns the workspace path for the transcript [W] line.
174
211
  * @returns async (req, next) => ApprovalOutcome
175
212
  */
176
- export function createHandler({ config, record, llmRunner, getSessionMode, denialFeed }) {
213
+ export function createHandler({ config, record, llmRunner, getSessionMode, denialFeed, denialHistory, getCwd }) {
177
214
  const cfg = config;
178
215
  const feed = denialFeed ?? new Map();
216
+ const history = denialHistory ?? new Map();
179
217
  const stageDenial = (sessionId, denial) => {
180
218
  if (sessionId === undefined || sessionId === null) return;
181
219
  const queue = feed.get(sessionId) ?? [];
182
220
  queue.push(denial);
183
221
  if (queue.length > cfg.denyFeedbackMax) queue.shift();
184
222
  feed.set(sessionId, queue);
223
+ const hq = history.get(sessionId) ?? [];
224
+ hq.push(denial);
225
+ if (hq.length > 5) hq.shift();
226
+ history.set(sessionId, hq);
185
227
  };
186
228
  return async (req, next) => {
187
229
  const started = Date.now();
@@ -200,14 +242,28 @@ export function createHandler({ config, record, llmRunner, getSessionMode, denia
200
242
  const matchReq = { toolName: req.toolName, argsText, reason: req.reason ?? "" };
201
243
 
202
244
  let verdict;
245
+ let context = "";
203
246
  const rule = evaluateRules(cfg.rules, matchReq);
204
247
  if (rule !== null) {
205
248
  verdict = { kind: "rule", action: rule.action, outcome: outcomeFor(rule.action), match: rule.match };
206
249
  } else if (cfg.ai.enabled) {
250
+ context = cfg.transcript === "short"
251
+ ? buildTranscript({
252
+ events: req.agent?.session?.events,
253
+ cfg,
254
+ denialHistory: history,
255
+ sessionId,
256
+ mode,
257
+ tolerance: cfg.ai.riskTolerance,
258
+ mode3OnAsk: cfg.mode3OnAsk,
259
+ cwd: getCwd !== undefined ? getCwd(req.agent) : undefined
260
+ })
261
+ : "";
207
262
  const judged = await judgeWith({
208
263
  runner: llmRunner,
209
- input: { toolName: req.toolName, argsText, reason: req.reason ?? "" },
210
- allowAsk: mode !== "ai-auto"
264
+ input: { toolName: req.toolName, argsText, reason: req.reason ?? "", context },
265
+ allowAsk: mode !== "ai-auto",
266
+ sessionId
211
267
  });
212
268
  if (judged.ok) {
213
269
  const authorization = decideAuthorization(judged.verdict, cfg.ai.riskTolerance);
@@ -247,6 +303,7 @@ export function createHandler({ config, record, llmRunner, getSessionMode, denia
247
303
  callId: req.callId,
248
304
  argsPreview: argsText.slice(0, 300),
249
305
  reason: (req.reason ?? "").slice(0, 500),
306
+ transcriptChars: context.length,
250
307
  ...verdict,
251
308
  ms: Date.now() - started
252
309
  });
@@ -444,13 +501,16 @@ export async function apply(ctx, userConfig) {
444
501
  const store = makeModeStore(ctx, ctx.logger);
445
502
  const getLocale = makeGetLocale(cfg, ctx);
446
503
  const denialFeed = new Map();
504
+ const denialHistory = new Map();
447
505
  const llmRunner = makeLlmRunner(ctx.llm, cfg.ai);
448
506
  const handler = createHandler({
449
507
  config: cfg,
450
508
  record: makeRecorder(cfg.logFile),
451
509
  llmRunner,
452
510
  getSessionMode: (sessionId) => store.get(sessionId),
453
- denialFeed
511
+ denialFeed,
512
+ denialHistory,
513
+ getCwd: (agent) => agent?.session?.policy?.workspaceRoot ?? agent?.cwd
454
514
  });
455
515
  ctx.on("approval/request", handler);
456
516
  // Rejection-attribution feedback: inject staged denials into the next
@@ -472,7 +532,9 @@ export async function apply(ctx, userConfig) {
472
532
  tolerance: cfg.ai.riskTolerance,
473
533
  fallback: cfg.fallback,
474
534
  denyFeedback: cfg.denyFeedback,
475
- denyFeedbackMax: cfg.denyFeedbackMax
535
+ denyFeedbackMax: cfg.denyFeedbackMax,
536
+ transcript: cfg.transcript,
537
+ transcriptMaxChars: cfg.transcriptMaxChars
476
538
  });
477
539
  ctx.logger?.info?.("[dsh-codex-approval] answerer registered — mode=%s rules=%d ai=%s tolerance=%s log=%s",
478
540
  cfg.mode, cfg.rules.length, cfg.ai.enabled ? "on" : "off", cfg.ai.riskTolerance, cfg.logFile);
package/judge.js CHANGED
@@ -23,8 +23,11 @@ Rules of thumb:
23
23
  - Reading files, git status/diff/log, listing, help output: low.
24
24
  - Writes inside a project, installs, network fetches: medium.
25
25
  - Deleting data, overwriting configs, exposing secrets, privilege changes, formatting disks, anything touching credentials: high.
26
+ - User intent matters: an action the user explicitly requested in the recent conversation is "allow"-eligible even if nominally medium-risk; never "ask"/"deny" a command the user just asked for unless it is clearly high-risk.
26
27
  - When uncertain, prefer "ask". Prefer "deny" for destructive or credential-exposing actions.
27
28
 
29
+ An optional "Context:" block may follow the request JSON — it is a compact, truncated session transcript ([U] user messages, [T] tool calls, [R] results, [D] recent denials, [M] mode). Use it to judge intent; ignore unrelated lines.
30
+
28
31
  Reply with ONLY one JSON object, no prose, no markdown fences:
29
32
  {"risk":"low|medium|high","authorization":"allow|ask|deny","reason":"one short sentence"}`;
30
33
 
@@ -42,19 +45,25 @@ const SYSTEM_PROMPT_NO_ASK = SYSTEM_PROMPT.replace(
42
45
 
43
46
  /**
44
47
  * Build the messages array for the judge call.
48
+ * @param opts - { toolName, argsText, reason, context }
49
+ * `context` is an optional compact session transcript (transcript.js);
50
+ * when present it is appended as a "Context:" block after the request JSON.
45
51
  * @param allowAsk - when false (ai-auto mode), the prompt forbids "ask":
46
52
  * the judge must commit to allow or deny.
47
53
  */
48
- export function buildJudgeMessages({ toolName, argsText, reason }, { allowAsk = true } = {}) {
54
+ export function buildJudgeMessages({ toolName, argsText, reason, context }, { allowAsk = true } = {}) {
49
55
  const user = JSON.stringify({
50
56
  toolName,
51
57
  command: argsText === "" ? null : argsText,
52
58
  reason: reason ?? null
53
59
  });
54
60
  const system = allowAsk ? SYSTEM_PROMPT : SYSTEM_PROMPT_NO_ASK;
61
+ const body = context !== undefined && context !== ""
62
+ ? `${user}\n\nContext:\n${context}`
63
+ : user;
55
64
  return [{
56
65
  role: "user",
57
- content: [{ type: "text", text: `${system}\n\n${user}` }]
66
+ content: [{ type: "text", text: `${system}\n\n${body}` }]
58
67
  }];
59
68
  }
60
69
 
@@ -140,16 +149,19 @@ export function decideAuthorization(verdict, tolerance) {
140
149
 
141
150
  /**
142
151
  * Run the judge through an injected runner.
143
- * @param runner - async (messages, { signal }) => Promise<{ ok: boolean, text: string }>
144
- * @param input - { toolName, argsText, reason }
152
+ * @param runner - async (messages, { signal, sessionId }) => Promise<{ ok: boolean, text: string }>
153
+ * @param input - { toolName, argsText, reason, context }
145
154
  * @param config - { maxPromptChars } (unused here; kept for symmetry)
155
+ * @param sessionId - optional stable per-conversation id forwarded to the LLM
156
+ * call so the provider can optimize prompt caching (e.g. OpenCode Go's
157
+ * `x-opencode-session` header).
146
158
  * @returns { ok: true, verdict } | { ok: false, error }
147
159
  */
148
- export async function judgeWith({ runner, input, signal, allowAsk = true }) {
160
+ export async function judgeWith({ runner, input, signal, allowAsk = true, sessionId }) {
149
161
  const messages = buildJudgeMessages(input, { allowAsk });
150
162
  let result;
151
163
  try {
152
- result = await runner(messages, { signal });
164
+ result = await runner(messages, { signal, sessionId });
153
165
  } catch (error) {
154
166
  return { ok: false, error: String(error?.message ?? error) };
155
167
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-codex-approval",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Codex-style approval autopilot for DeepSeek Harness: ordered glob rules (allow/ask/deny) plus an AI risk judge (low/medium/high) mapped through a risk tolerance, as an approval answerer.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -11,6 +11,7 @@
11
11
  "i18n.js",
12
12
  "judge.js",
13
13
  "modes.js",
14
+ "transcript.js",
14
15
  "cordis.patch.yml",
15
16
  "README.md",
16
17
  "LICENSE"
package/transcript.js ADDED
@@ -0,0 +1,225 @@
1
+ /**
2
+ * dsh-codex-approval — transcript.js
3
+ *
4
+ * Compact-session-transcript builder (`transcript: "short"`). Turns the live
5
+ * session event stream into a bounded, skeletonized context block for the AI
6
+ * approval judge, so the judge can see user intent and the surrounding tool
7
+ * chain — not just the bare command.
8
+ *
9
+ * Pipeline:
10
+ * 1. semantic filter — keep only user/message, tool-calls, tool/results,
11
+ * turn markers; drop streaming chunks and plugin-
12
+ * sourced user messages (denyFeedback / time-context
13
+ * injections must never be fed back to the judge).
14
+ * 2. two-level window — short window (recent user message + ≤3 tool
15
+ * calls) rendered in full skeleton; long window
16
+ * (older user messages only) as an intent line.
17
+ * 3. head/tail truncation — overlong messages keep head + tail with an
18
+ * elision counter (error-report pastes: head =
19
+ * action, tail = crux, middle = noise).
20
+ * 4. budget tiers — total bounded by transcriptMaxChars; overflow
21
+ * drops lowest-priority items first (denial history
22
+ * → cwd → oldest long-window entries).
23
+ *
24
+ * Pure functions only; everything defensive, never throws into the
25
+ * approval path.
26
+ */
27
+
28
+ /** Head/tail caps per item class, in chars. */
29
+ const CAPS = {
30
+ shortUser: { head: 600, tail: 600 }, // the most recent user message (P0)
31
+ longUser: { head: 120, tail: 80 }, // older intent-line entries (P2)
32
+ tool: { head: 200, tail: 0 } // tool-call arguments (P1)
33
+ };
34
+
35
+ /** Elide the middle of an over-long text: `head…〔省略 N 字符〕…tail`. */
36
+ export function truncateMiddle(text, headChars, tailChars) {
37
+ if (typeof text !== "string" || text.length <= headChars + tailChars) return text;
38
+ const head = text.slice(0, headChars);
39
+ const tail = tailChars > 0 ? text.slice(text.length - tailChars) : "";
40
+ const omitted = text.length - headChars - tailChars;
41
+ return omitted > 0
42
+ ? `${head}…〔省略 ${omitted} 字符〕…${tail}`
43
+ : text;
44
+ }
45
+
46
+ /**
47
+ * Extract the text of a user/message event. Returns "" for non-text shapes.
48
+ */
49
+ function userText(data) {
50
+ const content = data?.content;
51
+ if (Array.isArray(content)) {
52
+ return content
53
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
54
+ .map((part) => part.text)
55
+ .join(" ");
56
+ }
57
+ if (content && content.type === "text" && typeof content.text === "string") return content.text;
58
+ return "";
59
+ }
60
+
61
+ /**
62
+ * Collect semantic items from the raw event stream, newest first.
63
+ * Plugin-sourced user messages and streaming chunks are excluded here.
64
+ * @param events - session.events
65
+ * @returns array of { seq, kind, ... } with seq counting only semantic items
66
+ * (newest first, so index 0 is the most recent).
67
+ */
68
+ export function collectSemanticItems(events) {
69
+ if (!Array.isArray(events)) return [];
70
+ const items = [];
71
+ for (let i = events.length - 1; i >= 0; i -= 1) {
72
+ const event = events[i];
73
+ if (event === null || typeof event !== "object") continue;
74
+ const type = event.type;
75
+ if (type === "user/message") {
76
+ const source = event.data?.source;
77
+ if (source?.kind === "plugin") continue; // never feed injections back
78
+ const text = userText(event.data);
79
+ if (text === "") continue;
80
+ items.push({ seq: items.length, kind: "user", text, time: event.time });
81
+ } else if (type === "assistant/message") {
82
+ const content = event.data?.message?.content;
83
+ if (!Array.isArray(content)) continue;
84
+ for (const part of content) {
85
+ if (part?.type !== "tool-call") continue;
86
+ const args = typeof part.arguments === "string" ? part.arguments : "";
87
+ items.push({ seq: items.length, kind: "tool", name: part.name, args, time: event.time });
88
+ }
89
+ } else if (type === "tool/result") {
90
+ const msg = event.data?.message;
91
+ const error = event.data?.error;
92
+ const text = typeof msg?.text === "string" ? msg.text
93
+ : Array.isArray(msg?.content)
94
+ ? msg.content.filter((p) => p?.type === "text" && typeof p.text === "string").map((p) => p.text).join(" ")
95
+ : "";
96
+ items.push({
97
+ seq: items.length,
98
+ kind: "result",
99
+ ok: error === undefined,
100
+ errorCode: error?.code,
101
+ text: text === "" ? "" : truncateMiddle(text, 120, 60),
102
+ time: event.time
103
+ });
104
+ }
105
+ }
106
+ return items;
107
+ }
108
+
109
+ /**
110
+ * Render a semantic item to one skeleton line.
111
+ */
112
+ export function renderItem(item) {
113
+ if (item.kind === "user") {
114
+ const elided = truncateMiddle(item.text, CAPS.shortUser.head, CAPS.shortUser.tail);
115
+ return `[U] 用户: ${elided}`;
116
+ }
117
+ if (item.kind === "tool") {
118
+ const args = truncateMiddle(item.args ?? "", CAPS.tool.head, CAPS.tool.tail);
119
+ return `[T] ${item.name}(${args})`;
120
+ }
121
+ if (item.kind === "result") {
122
+ const marker = item.ok ? "→ ok" : `→ error${item.errorCode ? ` (${item.errorCode})` : ""}`;
123
+ const extra = item.text === "" ? "" : ` ${truncateMiddle(item.text, 80, 40)}`;
124
+ return `[R] ${marker}${extra}`;
125
+ }
126
+ return "";
127
+ }
128
+
129
+ /**
130
+ * Build the compact transcript for the judge.
131
+ *
132
+ * @param opts - {
133
+ * events, session.events
134
+ * cfg, plugin config (uses transcriptMaxChars)
135
+ * denialHistory, Map<sessionId, Array<...>> — recent denials (≤5 kept)
136
+ * sessionId, for denial history lookup
137
+ * mode, tolerance, effective mode / risk tolerance lines
138
+ * mode3OnAsk, cwd, optional context lines
139
+ * }
140
+ * @returns the bounded transcript text; "" when there is nothing to show.
141
+ */
142
+ export function buildTranscript({ events, cfg, denialHistory, sessionId, mode, tolerance, mode3OnAsk, cwd } = {}) {
143
+ const maxChars = cfg?.transcriptMaxChars ?? 4000;
144
+ const items = collectSemanticItems(events);
145
+ if (items.length === 0) return "";
146
+
147
+ // Two-level window split (items are newest-first).
148
+ const firstUserIdx = items.findIndex((item) => item.kind === "user");
149
+ const shortItems = [];
150
+ const longUsers = [];
151
+ let userBudget = 0;
152
+ if (firstUserIdx !== -1) {
153
+ // Short window: the newest user message + up to 3 tool items around it.
154
+ shortItems.push(items[firstUserIdx]);
155
+ let tools = 0;
156
+ for (let i = firstUserIdx - 1; i >= 0 && tools < 3; i -= 1) {
157
+ if (items[i].kind === "tool") {
158
+ shortItems.push(items[i]);
159
+ tools += 1;
160
+ }
161
+ }
162
+ // Long window: every older user message (intent line), capped per entry.
163
+ for (let i = items.length - 1; i > firstUserIdx; i -= 1) {
164
+ if (items[i].kind === "user") {
165
+ longUsers.push(truncateMiddle(items[i].text, CAPS.longUser.head, CAPS.longUser.tail));
166
+ }
167
+ }
168
+ }
169
+ // Fallback: no user message at all (e.g. fresh session) — keep recent tools.
170
+ const fallbackTools = firstUserIdx === -1
171
+ ? items.filter((item) => item.kind === "tool" || item.kind === "result").slice(0, 4)
172
+ : [];
173
+
174
+ // Build sections in stable-prefix order (oldest first) for prefix caching.
175
+ const sections = [];
176
+ if (mode !== undefined) {
177
+ const modeLine = `mode: ${mode}${tolerance !== undefined ? `, tolerance: ${tolerance}` : ""}${mode3OnAsk !== undefined ? `, mode3OnAsk: ${mode3OnAsk}` : ""}`;
178
+ sections.push(`[M] ${modeLine}`);
179
+ }
180
+ if (cwd !== undefined && cwd !== "") sections.push(`[W] ${cwd}`);
181
+ for (const line of longUsers) sections.push(`[U] 用户: ${line}`);
182
+ for (const item of [...fallbackTools].reverse()) sections.push(renderItem(item));
183
+ for (const item of [...shortItems].reverse()) sections.push(renderItem(item));
184
+
185
+ // Denial history (P2, dropped first on overflow).
186
+ const denials = denialHistory?.get(sessionId) ?? [];
187
+ const denialLines = denials.slice(-3).map((d) => {
188
+ const src = d.source ?? "?";
189
+ const risk = d.risk !== undefined ? `, risk: ${d.risk}` : "";
190
+ const cmd = truncateMiddle(d.command ?? "", 80, 0);
191
+ return `[D] deny ${cmd} (${src}${risk})`;
192
+ });
193
+
194
+ // Budget: assemble; on overflow drop denial history, then oldest
195
+ // long-window entries, then oldest tool lines; final hard cut keeps the
196
+ // head and tail (mode line / newest user message are protected).
197
+ const joinLen = (lines) => lines.reduce((sum, line) => sum + line.length + 1, 0) - (lines.length > 0 ? 1 : 0);
198
+ let lines = [...sections, ...denialLines];
199
+ let text;
200
+ if (joinLen(lines) <= maxChars) {
201
+ text = lines.join("\n");
202
+ } else {
203
+ // 1) drop denial history entirely
204
+ lines = [...sections];
205
+ // 2) drop droppable lines from the oldest (top), protecting the first
206
+ // two lines ([M] mode / [W] cwd) and the last line (newest user).
207
+ while (joinLen(lines) > maxChars && lines.length > 3) {
208
+ lines.splice(2, 1);
209
+ }
210
+ text = lines.join("\n");
211
+ // 3) final hard cut — the elision marker costs ~12 chars itself, so
212
+ // shrink head/tail until the cut truly fits inside the cap.
213
+ let headChars = Math.floor(maxChars * 0.6);
214
+ let tailChars = Math.floor(maxChars * 0.3);
215
+ let cut = truncateMiddle(text, headChars, tailChars);
216
+ while (cut.length > maxChars && (headChars > 8 || tailChars > 4)) {
217
+ headChars = Math.floor(headChars * 0.8);
218
+ tailChars = Math.floor(tailChars * 0.8);
219
+ cut = truncateMiddle(text, headChars, tailChars);
220
+ }
221
+ if (cut.length > maxChars) cut = `${text.slice(0, Math.max(8, maxChars - 4))}…`;
222
+ text = cut;
223
+ }
224
+ return text.trim();
225
+ }