dsh-decision-layer 0.7.2 → 0.7.4

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.
@@ -0,0 +1,206 @@
1
+ import { redactCredentials, stripInjected } from "./excerpt.js";
2
+ import { summarizeExecution } from "./danger-gate.js";
3
+ import { isObject } from "./types.js";
4
+ function clip(text, maxBytes) {
5
+ let size = 0;
6
+ let result = "";
7
+ for (const char of text) {
8
+ size += Buffer.byteLength(char);
9
+ if (size > maxBytes) break;
10
+ result += char;
11
+ }
12
+ return result;
13
+ }
14
+ function decisionText(value, maxBytes = 1536) {
15
+ if (typeof value !== "string" || maxBytes <= 0) return "";
16
+ const clean = redactCredentials(stripInjected(value.slice(0, 32 * 1024))).trim();
17
+ return clip(clean, maxBytes);
18
+ }
19
+ function clipTail(text, maxBytes) {
20
+ let size = 0;
21
+ let result = "";
22
+ const chars = Array.from(text);
23
+ for (let index = chars.length - 1; index >= 0; index--) {
24
+ const char = chars[index];
25
+ size += Buffer.byteLength(char);
26
+ if (size > maxBytes) break;
27
+ result = char + result;
28
+ }
29
+ return result;
30
+ }
31
+ function decisionTail(value, maxBytes = 512) {
32
+ if (typeof value !== "string" || maxBytes <= 0) return "";
33
+ const clean = redactCredentials(stripInjected(value.slice(0, 32 * 1024))).trim();
34
+ return clipTail(clean, maxBytes);
35
+ }
36
+ const OPAQUE = /[A-Za-z0-9_+-]{24,}/g;
37
+ const resultExcerpt = (value, maxBytes) => clip(decisionText(value, 32 * 1024).replace(OPAQUE, "[redacted]"), maxBytes);
38
+ const messagesOf = (value) => Array.isArray(value) ? value.filter(isObject) : [];
39
+ const textOf = (message) => Array.isArray(message.content) ? message.content.filter(isObject).filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n") : "";
40
+ const human = (message) => message.role === "user" && (!message.source || isObject(message.source) && message.source.kind === "user");
41
+ function historyOf(agent) {
42
+ try {
43
+ const derived = agent?.session?.deriveMessages?.();
44
+ return Array.isArray(derived) ? messagesOf(derived) : void 0;
45
+ } catch {
46
+ return void 0;
47
+ }
48
+ }
49
+ function backgroundOf(messages, isHuman) {
50
+ const lastUser = messages.findLastIndex(isHuman);
51
+ const lastAssistant = messages.findLastIndex((message) => message.role === "assistant");
52
+ return [lastUser, lastAssistant].filter((index) => index >= 0).sort((a, b) => a - b).map((index) => {
53
+ const message = messages[index];
54
+ if (!message) return void 0;
55
+ const role = isHuman(message) ? "user" : "assistant";
56
+ const raw = textOf(message);
57
+ const excerpt = role === "user" ? decisionText(raw, 512) : decisionTail(raw, 512);
58
+ return { role, text: excerpt };
59
+ }).filter((item) => Boolean(item?.text));
60
+ }
61
+ function evidenceOf(messages) {
62
+ const calls = /* @__PURE__ */ new Map();
63
+ const evidence = [];
64
+ for (const message of messages) {
65
+ if (message.role === "assistant" && Array.isArray(message.content)) {
66
+ for (const block of message.content.filter(isObject)) {
67
+ if (block.type !== "tool-call" || typeof block.id !== "string" || typeof block.name !== "string") continue;
68
+ let args = block.arguments;
69
+ if (typeof args === "string") {
70
+ try {
71
+ args = JSON.parse(args);
72
+ } catch {
73
+ args = void 0;
74
+ }
75
+ }
76
+ calls.set(block.id, { name: block.name, arguments: args });
77
+ }
78
+ }
79
+ if (message.role !== "tool") continue;
80
+ const source = isObject(message.source) ? message.source : {};
81
+ const id = typeof message.toolCallId === "string" ? message.toolCallId : source.callId;
82
+ const call = typeof id === "string" ? calls.get(id) : void 0;
83
+ const name = call?.name ?? (typeof message.name === "string" ? message.name : typeof message.toolName === "string" ? message.toolName : "");
84
+ if (!name) continue;
85
+ const item = { tool: decisionText(name, 96), outcome: message.isError === true ? "error" : "ok" };
86
+ if (call) {
87
+ const summary = summarizeExecution(call);
88
+ if (summary.command) item.command = decisionText(summary.command, 160);
89
+ if (summary.path) item.path = decisionText(summary.path, 160);
90
+ }
91
+ if (["bash", "pwsh", "shell", "exec", "terminal", "run_command"].includes(name)) {
92
+ const result = resultExcerpt(textOf(message), 256);
93
+ if (result) item.result = result;
94
+ }
95
+ evidence.push(item);
96
+ }
97
+ const bounded = evidence.length > 12 ? [...evidence.slice(0, 4), ...evidence.slice(-8)] : [...evidence];
98
+ while (Buffer.byteLength(JSON.stringify(bounded)) > 4096) bounded.shift();
99
+ return bounded;
100
+ }
101
+ const REPLACEMENTS = /* @__PURE__ */ new Set(["compact-checkpoint", "dsh-rewind"]);
102
+ function afterLastReplacement(messages) {
103
+ for (let i = messages.length - 1; i >= 0; i--) {
104
+ const message = messages[i];
105
+ if (!message) continue;
106
+ const source = isObject(message.source) ? message.source : void 0;
107
+ if (source && typeof source.kind === "string" && REPLACEMENTS.has(source.kind)) return messages.slice(i + 1);
108
+ }
109
+ return messages;
110
+ }
111
+ function boundaryHolds(history, boundary) {
112
+ if (boundary.start <= 0) return true;
113
+ const previous = history[boundary.start - 1];
114
+ if (!previous) return false;
115
+ return previous === boundary.anchor || boundary.anchorId !== void 0 && previous.id === boundary.anchorId;
116
+ }
117
+ function snapshotOf(history, boundary, isHuman) {
118
+ const messages = history ?? [];
119
+ const lastHuman = messages.findLastIndex(isHuman);
120
+ const window = boundary === void 0 ? messages.slice(lastHuman >= 0 ? lastHuman : messages.length) : boundary.broken ? [] : boundaryHolds(messages, boundary) ? messages.slice(boundary.start) : [];
121
+ const current = afterLastReplacement(window);
122
+ const requests = boundary?.requests ?? current.filter(isHuman).map((message) => decisionText(textOf(message), 8192));
123
+ const request = decisionText(requests.at(-1) ?? "", 1536);
124
+ const evidence = evidenceOf(current);
125
+ const context = {
126
+ request,
127
+ // Earlier real requests in this turn stay visible as background: with only the
128
+ // last one, a short follow-up ("继续") would hide the main task. Pre-turn
129
+ // context is kept too, but the size cap below drops it first.
130
+ background: [
131
+ ...boundary?.background ?? backgroundOf(messages.slice(0, Math.max(lastHuman, 0)), isHuman),
132
+ ...requests.slice(0, -1).slice(-2).map((text) => ({ role: "user", text: decisionText(text, 1024) }))
133
+ ].slice(-4),
134
+ progress: evidence.slice(-3)
135
+ };
136
+ while (Buffer.byteLength(JSON.stringify(context.progress)) > 1024 && context.progress.length > 1) context.progress.shift();
137
+ while (Buffer.byteLength(JSON.stringify(context)) > 4096 && (context.background.length || context.progress.length > 1)) {
138
+ if (context.background.length) context.background.shift();
139
+ else context.progress.shift();
140
+ }
141
+ return {
142
+ context,
143
+ evidence,
144
+ // Completion conditions come from the latest real request only. Joining a
145
+ // turn's requests would resurrect work the user just cancelled ("drop the
146
+ // earlier list, only review"); when the latest request has no explicit
147
+ // conditions the check skips rather than infer from an older revision.
148
+ conditionsText: requests.at(-1) ?? ""
149
+ };
150
+ }
151
+ function createDecisionContext({ ignored } = {}) {
152
+ const boundaries = /* @__PURE__ */ new WeakMap();
153
+ const isHuman = (message) => human(message) && !(typeof message.id === "string" && ignored?.(message.id) === true);
154
+ const enter = (agent, position) => {
155
+ const known = agent ? boundaries.get(agent) : void 0;
156
+ const first = !known || known.turn !== position.turn;
157
+ const inputs = messagesOf(position.messages).filter(isHuman).map((message) => decisionText(textOf(message), 8192)).filter(Boolean);
158
+ if (!first && inputs.length === 0) return { changed: false };
159
+ const history = historyOf(agent);
160
+ let boundary = known;
161
+ const anchor = history?.at(-1);
162
+ if (first) {
163
+ boundary = {
164
+ turn: position.turn,
165
+ start: history?.length ?? 0,
166
+ anchor,
167
+ broken: history === void 0,
168
+ ...isObject(anchor) && typeof anchor.id === "string" ? { anchorId: anchor.id } : {},
169
+ requests: [],
170
+ background: history ? backgroundOf(history, isHuman) : []
171
+ };
172
+ if (agent) boundaries.set(agent, boundary);
173
+ } else if (boundary?.broken) {
174
+ boundary.broken = history === void 0;
175
+ boundary.start = history?.length ?? 0;
176
+ boundary.anchor = anchor;
177
+ if (isObject(anchor) && typeof anchor.id === "string") boundary.anchorId = anchor.id;
178
+ else delete boundary.anchorId;
179
+ boundary.background = history ? backgroundOf(history, isHuman) : [];
180
+ }
181
+ const active = boundary;
182
+ active.requests.push(...inputs);
183
+ active.requests = active.requests.slice(-8);
184
+ return { changed: true, snapshot: snapshotOf(history, active, isHuman) };
185
+ };
186
+ return {
187
+ enter(agent, position) {
188
+ try {
189
+ return enter(agent, position);
190
+ } catch {
191
+ return { changed: false };
192
+ }
193
+ },
194
+ snapshot(agent) {
195
+ try {
196
+ return snapshotOf(historyOf(agent), agent ? boundaries.get(agent) : void 0, isHuman);
197
+ } catch {
198
+ return { context: { request: "", background: [], progress: [] }, conditionsText: "", evidence: [] };
199
+ }
200
+ }
201
+ };
202
+ }
203
+ export {
204
+ createDecisionContext,
205
+ decisionText
206
+ };
@@ -22,4 +22,5 @@ export declare const TASK_EXCERPT_LIMIT = 512;
22
22
  * a secret to the log.
23
23
  */
24
24
  export declare function redactCredentials(input: string): string;
25
+ export declare function stripInjected(input: string): string;
25
26
  export declare function taskExcerpt(state: unknown, limit?: number): string | undefined;
@@ -1,9 +1,11 @@
1
1
  const MAX_INPUT = 8 * 1024;
2
2
  const TASK_EXCERPT_LIMIT = 512;
3
3
  const TOKEN_KEYS = String.raw`token|api[ _-]*key`;
4
- const PHRASE_KEYS = String.raw`password|passwd|passphrase|secret`;
4
+ const PHRASE_KEYS = String.raw`password|passw(?:or)?d|passphrase|secret[ _-]?key|access[ _-]?key|private[ _-]?key|secret`;
5
5
  const CN_KEYS = String.raw`密码|口令|密码短语|密钥|令牌|凭证|私钥`;
6
6
  const COMBINED_KEYS = String.raw`${PHRASE_KEYS}|${TOKEN_KEYS}`;
7
+ const ENV_PHRASE_KEYS = String.raw`PASSW(?:OR)?D|SECRET|ACCESS[_-]?KEY|PRIVATE[_-]?KEY`;
8
+ const ENV_TOKEN_KEYS = String.raw`TOKEN|API[_-]?KEY`;
7
9
  const SEPARATOR = String.raw`[:=:=]`;
8
10
  const VALUE = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;},;、]+)`;
9
11
  const CLAUSE = String.raw`(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\r\n,;,;、]*)`;
@@ -30,6 +32,13 @@ const RULE = {
30
32
  asciiCopula: new RegExp(String.raw`(\b(?:${COMBINED_KEYS})\b\s*(?:${COPULA})\s*)${VALUE_LIKE}${VALUE}`, "gi"),
31
33
  cnCopula: new RegExp(String.raw`((?:${CN_KEYS})\s*(?:是|为)\s*)${VALUE_LIKE}${VALUE}`, "g"),
32
34
  flag: new RegExp(String.raw`(^|\s)(--(?:${COMBINED_KEYS})(?:\s+|=))${VALUE}`, "gi"),
35
+ // Env-style keys are often a suffix of a longer identifier (`DB_PASSWORD`,
36
+ // `AWS_SECRET_ACCESS_KEY`), where `\b` cannot match across `_`. JSON and quoted
37
+ // .env output place a closing quote between key and separator, and a quoted
38
+ // passphrase may contain spaces, so the key allows a quote and the value is the
39
+ // whole clause (over-redaction is the accepted direction).
40
+ envPhrase: new RegExp(String.raw`([A-Za-z0-9_]*(?:${ENV_PHRASE_KEYS})[A-Za-z0-9_]*["']?\s*${SEPARATOR}\s*)${CLAUSE}`, "gi"),
41
+ envToken: new RegExp(String.raw`([A-Za-z0-9_]*(?:${ENV_TOKEN_KEYS})[A-Za-z0-9_]*["']?\s*${SEPARATOR}\s*)${VALUE}`, "gi"),
33
42
  // Legacy space form (`token abc`), but never swallow a copula: the rule above
34
43
  // masks the value that follows it.
35
44
  loose: new RegExp(String.raw`(\b(?:${COMBINED_KEYS})\b\s+)(?!(?:${COPULA})(?:\s|$)|${SEPARATOR})${VALUE}`, "gi")
@@ -45,18 +54,23 @@ function redactCredentials(input) {
45
54
  text = text.replace(RULE.asciiCopula, "$1[redacted]");
46
55
  text = text.replace(RULE.cnCopula, "$1[redacted]");
47
56
  text = text.replace(RULE.flag, "$1$2[redacted]");
57
+ text = text.replace(RULE.envPhrase, "$1[redacted]");
58
+ text = text.replace(RULE.envToken, "$1[redacted]");
48
59
  text = text.replace(RULE.loose, "$1[redacted]");
49
60
  for (const [pattern, replacement] of TOKEN_SHAPES) text = text.replace(pattern, replacement);
50
61
  return text;
51
62
  }
63
+ function stripInjected(input) {
64
+ return input.replace(/<(openviking-context|system-reminder)\b[^>]*>[\s\S]*?<\/\1>/gi, " ").replace(/<(openviking-context|system-reminder)\b[\s\S]*$/i, " ");
65
+ }
52
66
  function taskExcerpt(state, limit = TASK_EXCERPT_LIMIT) {
53
67
  if (typeof state !== "string") return void 0;
54
- const stripped = state.slice(0, MAX_INPUT).replace(/<(openviking-context|system-reminder)\b[^>]*>[\s\S]*?<\/\1>/gi, " ").replace(/<(openviking-context|system-reminder)\b[\s\S]*$/i, " ");
55
- const text = redactCredentials(stripped).replace(/\s+/g, " ").trim();
68
+ const text = redactCredentials(stripInjected(state.slice(0, MAX_INPUT))).replace(/\s+/g, " ").trim();
56
69
  return text ? text.slice(0, limit) : void 0;
57
70
  }
58
71
  export {
59
72
  TASK_EXCERPT_LIMIT,
60
73
  redactCredentials,
74
+ stripInjected,
61
75
  taskExcerpt
62
76
  };
package/lib/host/index.js CHANGED
@@ -11,6 +11,7 @@ import { createTaskCompletion } from "./task-completion.js";
11
11
  import { createLoopGuard } from "./loop-guard.js";
12
12
  import { createLogStore } from "./log-store.js";
13
13
  import { createStateStore } from "./state-store.js";
14
+ import { createDecisionContext } from "./decision-context.js";
14
15
  import { analyzeSessionLogs, reconstructSessionSeed, listSessionDecisions } from "./log-analyze.js";
15
16
  import { isObject } from "./types.js";
16
17
  const name = "dsh-decision-layer";
@@ -35,50 +36,6 @@ function lastAssistantText(agent) {
35
36
  return "";
36
37
  }
37
38
  }
38
- function stepTaskText(messages) {
39
- try {
40
- if (!Array.isArray(messages)) return "";
41
- return messages.map(asMessage).filter((message) => message?.role === "user" && Array.isArray(message.content)).flatMap((message) => message.content.filter((block) => isObject(block) && block.type === "text" && typeof block.text === "string").map((block) => block.text)).join("\n").slice(0, 8 * 1024);
42
- } catch {
43
- return "";
44
- }
45
- }
46
- function turnUserText(agent) {
47
- try {
48
- const messages = derivedMessages(agent);
49
- if (!messages) return "";
50
- const texts = [];
51
- let collecting = false;
52
- for (let i = messages.length - 1; i >= 0; i--) {
53
- const message = asMessage(messages[i]);
54
- if (message?.role === "user" && Array.isArray(message.content)) {
55
- collecting = true;
56
- texts.unshift(message.content.filter((block) => isObject(block) && block.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n"));
57
- } else if (collecting) break;
58
- }
59
- return texts.join("\n").slice(0, 8 * 1024);
60
- } catch {
61
- return "";
62
- }
63
- }
64
- function turnToolEvidence(agent) {
65
- try {
66
- const messages = derivedMessages(agent);
67
- if (!messages) return [];
68
- const evidence = [];
69
- for (const item of messages) {
70
- const message = asMessage(item);
71
- if (message?.role !== "tool" || !Array.isArray(message.content)) continue;
72
- const tool = typeof message.name === "string" ? message.name : typeof message.toolName === "string" ? message.toolName : "";
73
- if (!tool) continue;
74
- evidence.push({ tool: tool.slice(0, 128), outcome: message.isError === true ? "error" : "ok" });
75
- if (evidence.length >= 40) break;
76
- }
77
- return evidence;
78
- } catch {
79
- return [];
80
- }
81
- }
82
39
  function agentTools(ctx, agent) {
83
40
  try {
84
41
  const schemas = typeof ctx.tools?.schemas === "function" ? ctx.tools.schemas(agent) : void 0;
@@ -108,6 +65,20 @@ async function apply(ctx, options = {}) {
108
65
  if (typeof ctx.effect === "function") ctx.effect(() => () => {
109
66
  void stateStore.dispose();
110
67
  });
68
+ const steeredIds = /* @__PURE__ */ new Map();
69
+ const rememberSteer = (message) => {
70
+ const id = isObject(message) && typeof message.id === "string" ? message.id : void 0;
71
+ if (!id) return;
72
+ steeredIds.set(id, true);
73
+ if (steeredIds.size > 256) steeredIds.delete(steeredIds.keys().next().value);
74
+ };
75
+ const steerAgent = (agent, text) => {
76
+ if (typeof agent.steer !== "function") throw new Error("Agent cannot be steered");
77
+ const message = createUserMessage({ content: [{ type: "text", text }], source: { kind: "user" } });
78
+ rememberSteer(message);
79
+ return agent.steer(message);
80
+ };
81
+ const decisionContext = createDecisionContext({ ignored: (id) => steeredIds.has(id) });
111
82
  const gate = createDangerGate({
112
83
  evaluate: (input, request) => backend.evaluate(input, request),
113
84
  sessions,
@@ -116,8 +87,10 @@ async function apply(ctx, options = {}) {
116
87
  // is at least this confident. Read per call so an edit takes effect at once.
117
88
  minConfidence: async () => (await resolveConfig({ path })).gateSettings?.minConfidence,
118
89
  // Read lazily (only when a dangerous call is recorded) so ordinary tool calls
119
- // never pay for deriveMessages().
120
- taskText: (agent) => turnUserText(agent)
90
+ // never pay for deriveMessages(). The gate itself still sends only the call
91
+ // summary; this excerpt is local-log context, from the same snapshot the other
92
+ // decision points use.
93
+ taskText: (agent) => decisionContext.snapshot(agent).context.request
121
94
  });
122
95
  const loopGuard = createLoopGuard({ sessions });
123
96
  const featureOn = async (key) => {
@@ -152,19 +125,17 @@ async function apply(ctx, options = {}) {
152
125
  evaluate: (input, request) => backend.evaluate(input, request),
153
126
  sessions,
154
127
  settings: async () => (await resolveConfig({ path })).checkSettings,
155
- steer: (agent, text) => {
156
- if (typeof agent.steer !== "function") throw new Error("Agent cannot be steered");
157
- return agent.steer(createUserMessage({ content: [{ type: "text", text }], source: { kind: "user" } }));
158
- }
128
+ steer: steerAgent
159
129
  });
160
130
  const installCheck = () => ctx.on("agent/turn-stopping", async (payload) => {
161
131
  if (!await featureOn("check")) return;
132
+ const snapshot = decisionContext.snapshot(payload.agent);
162
133
  return selfCheck.review({
163
134
  agent: payload.agent,
164
135
  turn: payload.turn,
165
136
  signal: payload.signal,
166
137
  output: lastAssistantText(payload.agent),
167
- userText: turnUserText(payload.agent)
138
+ context: snapshot.context
168
139
  });
169
140
  });
170
141
  if (typeof ctx.effect === "function") ctx.effect(installCheck);
@@ -173,20 +144,21 @@ async function apply(ctx, options = {}) {
173
144
  evaluate: (input, request) => backend.evaluate(input, request),
174
145
  sessions,
175
146
  settings: async () => (await resolveConfig({ path })).completeSettings,
176
- steer: (agent, text) => {
177
- if (typeof agent.steer !== "function") throw new Error("Agent cannot be steered");
178
- return agent.steer(createUserMessage({ content: [{ type: "text", text }], source: { kind: "user" } }));
179
- }
147
+ steer: steerAgent
180
148
  });
181
149
  const installCompletion = () => ctx.on("agent/turn-stopping", async (payload) => {
182
150
  if (!await featureOn("complete")) return;
151
+ const snapshot = decisionContext.snapshot(payload.agent);
183
152
  return taskCompletion.review({
184
153
  agent: payload.agent,
185
154
  turn: payload.turn,
186
155
  signal: payload.signal,
187
156
  output: lastAssistantText(payload.agent),
188
- userText: turnUserText(payload.agent),
189
- evidence: turnToolEvidence(payload.agent)
157
+ // Conditions come only from this turn's real user text; evidence only from
158
+ // this turn's window, so an earlier task's successes cannot vouch for it.
159
+ userText: snapshot.conditionsText,
160
+ evidence: snapshot.evidence,
161
+ context: snapshot.context
190
162
  });
191
163
  });
192
164
  if (typeof ctx.effect === "function") ctx.effect(installCompletion);
@@ -197,46 +169,71 @@ async function apply(ctx, options = {}) {
197
169
  settings: async () => (await resolveConfig({ path })).narrowSettings
198
170
  });
199
171
  const narrowedTurns = /* @__PURE__ */ new Map();
200
- const installNarrowingHook = () => ctx.on("agent/pre-step", async (payload, next) => {
201
- const agent = payload?.agent;
202
- const id = agent?.session?.id;
203
- const turn = payload?.turn;
204
- if (typeof id === "string") await sessions.ensureLoaded(id);
205
- if (typeof id === "string" && Number.isInteger(turn)) {
206
- const state = narrowedTurns.get(id);
207
- if (!state || state.turn !== turn) {
208
- state?.dispose?.();
209
- narrowedTurns.delete(id);
210
- if (await featureOn("narrow")) {
211
- const entry = { turn, dispose: void 0 };
212
- narrowedTurns.set(id, entry);
213
- if (narrowedTurns.size > 1024) {
214
- const oldest = narrowedTurns.keys().next().value;
215
- if (oldest !== id) {
216
- narrowedTurns.get(oldest)?.dispose?.();
217
- narrowedTurns.delete(oldest);
172
+ const releaseNarrowing = (id) => {
173
+ const entry = narrowedTurns.get(id);
174
+ if (!entry) return;
175
+ try {
176
+ entry.dispose?.();
177
+ } catch {
178
+ }
179
+ narrowedTurns.delete(id);
180
+ };
181
+ const installNarrowingHook = () => {
182
+ const disposeHook = ctx.on("agent/pre-step", async (payload, next) => {
183
+ const agent = payload?.agent;
184
+ const id = agent?.session?.id;
185
+ const turn = payload?.turn;
186
+ if (typeof id === "string") await sessions.ensureLoaded(id);
187
+ if (typeof id === "string" && Number.isInteger(turn)) {
188
+ const entered = decisionContext.enter(agent, { turn, messages: payload.messages });
189
+ const previous = narrowedTurns.get(id);
190
+ const turnChanged = !previous || previous.turn !== turn;
191
+ if (turnChanged || entered.changed) {
192
+ releaseNarrowing(id);
193
+ if (await featureOn("narrow")) {
194
+ const entry = { turn, dispose: void 0 };
195
+ narrowedTurns.set(id, entry);
196
+ if (narrowedTurns.size > 1024) {
197
+ const oldest = narrowedTurns.keys().next().value;
198
+ if (oldest !== id) releaseNarrowing(oldest);
218
199
  }
219
- }
220
- try {
221
- const result = await narrowing.review({
222
- agent,
223
- turn,
224
- signal: payload.signal,
225
- state: stepTaskText(payload.messages),
226
- tools: agentTools(ctx, agent)
227
- });
228
- if (result.mode === "enforce" && result.applied && typeof agent?.ctx?.tools?.restrict === "function") {
229
- entry.dispose = agent.ctx.tools.restrict({ allow: result.keep });
200
+ try {
201
+ const { context } = entered.snapshot ?? decisionContext.snapshot(agent);
202
+ const result = await narrowing.review({
203
+ agent,
204
+ turn,
205
+ step: payload.step,
206
+ signal: payload.signal,
207
+ state: { request: context.request, background: context.background, progress: context.progress },
208
+ taskText: context.request,
209
+ tools: agentTools(ctx, agent)
210
+ });
211
+ if (result.mode === "enforce" && result.applied && typeof agent?.ctx?.tools?.restrict === "function") {
212
+ const restriction = agent.ctx.tools.restrict({ allow: result.keep });
213
+ if (narrowedTurns.get(id) === entry) entry.dispose = restriction;
214
+ else restriction?.();
215
+ }
216
+ } catch {
230
217
  }
231
- } catch {
232
218
  }
233
219
  }
234
220
  }
235
- }
236
- return next();
237
- });
221
+ return next();
222
+ });
223
+ return () => {
224
+ disposeHook?.();
225
+ for (const key of [...narrowedTurns.keys()]) releaseNarrowing(key);
226
+ };
227
+ };
238
228
  if (typeof ctx.effect === "function") ctx.effect(installNarrowingHook);
239
229
  else installNarrowingHook();
230
+ const installNarrowingRelease = () => ctx.on("session/event", (subject, event) => {
231
+ if (!isObject(event) || event.type !== "turn/end") return;
232
+ const id = isObject(subject) && typeof subject.id === "string" ? subject.id : void 0;
233
+ if (id !== void 0) releaseNarrowing(id);
234
+ });
235
+ if (typeof ctx.effect === "function") ctx.effect(installNarrowingRelease);
236
+ else installNarrowingRelease();
240
237
  }
241
238
  const rawSkill = await readFile(new URL("../../skills/decision-layer/SKILL.md", import.meta.url), "utf8");
242
239
  const content = rawSkill.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "").trim();
@@ -273,7 +270,7 @@ async function apply(ctx, options = {}) {
273
270
  port,
274
271
  trendConfig: (await resolveConfig({ path })).checkSettings
275
272
  });
276
- const listRows = (sessionId, { page, pageSize } = {}) => listSessionDecisions(sessionId, { port, page, pageSize });
273
+ const listRows = (sessionId, { page, pageSize, lowOnly } = {}) => listSessionDecisions(sessionId, { port, page, pageSize, lowOnly });
277
274
  const unregister = createManagerRoutes({ path, sessions, backend, logStore, analyze, listRows, bindHost: web.webServer.host }).map((route) => web.webServer.register(route));
278
275
  return () => {
279
276
  unregister.forEach((dispose) => dispose());
@@ -106,11 +106,12 @@ export declare function reconstructSessionSeed(sessionId: unknown, { dir, port,
106
106
  maxLog?: number;
107
107
  readStream?: ReadStream;
108
108
  }): Promise<SessionSeed | undefined>;
109
- export declare function listSessionDecisions(sessionId: unknown, { dir, port, page, pageSize, readStream }?: {
109
+ export declare function listSessionDecisions(sessionId: unknown, { dir, port, page, pageSize, lowOnly, readStream }?: {
110
110
  dir?: string;
111
111
  port?: number;
112
112
  page?: number;
113
113
  pageSize?: number;
114
+ lowOnly?: boolean;
114
115
  readStream?: ReadStream;
115
116
  }): Promise<{
116
117
  entries: LogRow[];
@@ -220,7 +220,7 @@ async function reconstructSessionSeed(sessionId, { dir, port, trendConfig, maxLo
220
220
  if (decisions.size === 0) return void 0;
221
221
  return buildSeed(decisions, { maxLog, trendConfig });
222
222
  }
223
- async function listSessionDecisions(sessionId, { dir, port, page = 1, pageSize = 100, readStream = createReadableStream } = {}) {
223
+ async function listSessionDecisions(sessionId, { dir, port, page = 1, pageSize = 100, lowOnly = false, readStream = createReadableStream } = {}) {
224
224
  if (typeof sessionId !== "string" || !sessionId.trim()) throw new Error("Invalid session id");
225
225
  const size = Number.isSafeInteger(pageSize) && pageSize > 0 && pageSize <= 500 ? pageSize : 100;
226
226
  const records = [];
@@ -229,7 +229,8 @@ async function listSessionDecisions(sessionId, { dir, port, page = 1, pageSize =
229
229
  if (record.sessionId === sessionId) records.push(record);
230
230
  }
231
231
  const { decisions } = foldSession(records);
232
- const rows = [...decisions.values()].sort((a, b) => atOf(b) - atOf(a));
232
+ const all = [...decisions.values()].sort((a, b) => atOf(b) - atOf(a));
233
+ const rows = lowOnly ? all.filter((record) => record.kind === "check" && record.outcome === "low") : all;
233
234
  const total = rows.length;
234
235
  const pages = Math.max(1, Math.ceil(total / size));
235
236
  const current = Math.min(Math.max(1, Number.isSafeInteger(page) ? page : 1), pages);
@@ -13,6 +13,7 @@ export type ManagerRouteDeps = {
13
13
  listRows?: (id: string, options: {
14
14
  page: number;
15
15
  pageSize: number;
16
+ lowOnly?: boolean;
16
17
  }) => Promise<unknown>;
17
18
  env?: Record<string, string | undefined>;
18
19
  bindHost?: string;
@@ -111,7 +111,9 @@ function createManagerRoutes({ path, sessions, backend, logStore, analyze, listR
111
111
  }
112
112
  }),
113
113
  // One newest-first page of this session's full persisted decision rows, for
114
- // the analysis tab's paginated log. Rows carry the same panel fields as /log,
114
+ // the analysis tab's paginated log. `lowOnly=1` filters to low self-check
115
+ // scores before paging, so a single low score in a long session is one click
116
+ // away instead of a manual scan. Rows carry the same panel fields as /log,
115
117
  // including the gate's already-redacted and length-bounded command/path, so
116
118
  // this exposes nothing the recent-rows route does not already.
117
119
  route("logrows", {
@@ -121,8 +123,9 @@ function createManagerRoutes({ path, sessions, backend, logStore, analyze, listR
121
123
  const params = new URL(req.url ?? "/", "http://localhost").searchParams;
122
124
  const page = Number.parseInt(params.get("page") ?? "1", 10);
123
125
  const pageSize = Number.parseInt(params.get("pageSize") ?? "100", 10);
126
+ const lowOnly = params.get("lowOnly") === "1";
124
127
  try {
125
- return await listRows(id, { page: Number.isNaN(page) ? 1 : page, pageSize: Number.isNaN(pageSize) ? 100 : pageSize });
128
+ return await listRows(id, { page: Number.isNaN(page) ? 1 : page, pageSize: Number.isNaN(pageSize) ? 100 : pageSize, ...lowOnly ? { lowOnly } : {} });
126
129
  } catch (error) {
127
130
  if (error?.code === "BAD_REQUEST") throw error;
128
131
  throw codedError("log rows failed", "INTERNAL");
@@ -19,8 +19,11 @@ export declare function relevanceQuestions(state: unknown, tools: readonly ToolC
19
19
  export type NarrowTurn = {
20
20
  agent?: AgentLike;
21
21
  turn?: number;
22
+ step?: number;
22
23
  signal?: AbortSignal;
23
24
  state?: unknown;
25
+ /** Raw request text for the log row; `state` may now be a structured object. */
26
+ taskText?: unknown;
24
27
  tools?: unknown;
25
28
  };
26
29
  export type NarrowResult = {
@@ -106,9 +106,10 @@ function createNarrowing({
106
106
  }
107
107
  if (typeof sessions.log !== "function") return;
108
108
  try {
109
- const task = taskExcerpt(turn?.state);
109
+ const task = taskExcerpt(turn?.taskText ?? turn?.state);
110
110
  const context = task ? { task } : {};
111
111
  if (Number.isSafeInteger(turn?.turn) && turn?.turn >= 0) context.turn = turn?.turn;
112
+ if (Number.isSafeInteger(turn?.step) && turn?.step >= 0) context.step = turn?.step;
112
113
  sessions.log(id, { kind: "narrow", outcome, ...context, ...detail });
113
114
  } catch {
114
115
  }
@@ -146,7 +147,7 @@ function createNarrowing({
146
147
  const keptNames = names.filter(alwaysKeep);
147
148
  const optional = tools.filter((tool) => !alwaysKeep(tool.name));
148
149
  if (optional.length < minCandidates) return { ...skip, keep: names };
149
- const hasState = typeof turn?.state === "string" ? turn.state.trim().length > 0 : isObject(turn?.state) ? Object.keys(turn.state).length > 0 : false;
150
+ const hasState = typeof turn?.taskText === "string" ? turn.taskText.trim().length > 0 : typeof turn?.state === "string" ? turn.state.trim().length > 0 : isObject(turn?.state) ? Object.keys(turn.state).length > 0 : false;
150
151
  if (!hasState) return { ...skip, keep: names };
151
152
  const id = turn?.agent?.session?.id;
152
153
  if (sessions && typeof id === "string") {
@@ -1,6 +1,6 @@
1
- import type { AgentLike, CheckSettings, EvaluateInput, Evaluator, SessionsFace } from './types.js';
1
+ import type { AgentLike, CheckSettings, DecisionContext, EvaluateInput, Evaluator, SessionsFace } from './types.js';
2
2
  export declare const DEFAULT_RUBRIC: readonly string[];
3
- export declare function scoreQuestion(output: unknown, rubric?: readonly string[]): EvaluateInput;
3
+ export declare function scoreQuestion(output: unknown, rubric?: readonly string[], context?: DecisionContext): EvaluateInput;
4
4
  export declare const DEFAULT_MIN_CONFIDENCE = 0.4;
5
5
  export type CheckTurn = {
6
6
  agent?: AgentLike;
@@ -8,6 +8,7 @@ export type CheckTurn = {
8
8
  signal?: AbortSignal;
9
9
  output?: unknown;
10
10
  userText?: unknown;
11
+ context?: DecisionContext;
11
12
  };
12
13
  export type CheckResult = {
13
14
  evaluated: boolean;