dsh-decision-layer 0.4.0 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-decision-layer",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "A structured decision layer for the DeepSeek Harness agent loop: a danger-call gate, output self-check, tool narrowing, and loop guard backed by a pluggable adjudication model.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -49,6 +49,7 @@
49
49
  },
50
50
  "devDependencies": {
51
51
  "@deepseek-ai/dsh-client-locale": "0.1.7-rc.2",
52
+ "@deepseek-ai/dsh-client-ui-conversation": "0.1.7-rc.2",
52
53
  "@deepseek-ai/dsh-client-ui-plugin-manager": "0.1.7-rc.2",
53
54
  "@deepseek-ai/dsh-client-ui-renderer": "0.1.7-rc.2",
54
55
  "@deepseek-ai/dsh-client-ui-slots": "0.1.7-rc.2",
@@ -70,7 +71,8 @@
70
71
  "@deepseek-ai/dsh-client-ui-renderer",
71
72
  "@deepseek-ai/dsh-client-ui-slots",
72
73
  "@deepseek-ai/dsh-client-locale",
73
- "@deepseek-ai/dsh-client-ui-plugin-manager"
74
+ "@deepseek-ai/dsh-client-ui-plugin-manager",
75
+ "@deepseek-ai/dsh-client-ui-conversation"
74
76
  ],
75
77
  "platform": "web"
76
78
  }
@@ -55,7 +55,7 @@ function validRules(value) {
55
55
 
56
56
  function validCheck(value) {
57
57
  if (!safeObject(value)) return undefined;
58
- if (Object.keys(value).some(key => !['mode', 'rubric', 'lowScoreThreshold'].includes(key))) throw new Error('Invalid self-check settings');
58
+ if (Object.keys(value).some(key => !['mode', 'rubric', 'lowScoreThreshold', 'trendMinConfidence', 'trendRun', 'trendSevereRun'].includes(key))) throw new Error('Invalid self-check settings');
59
59
  const check = {};
60
60
  if (value.mode !== undefined) {
61
61
  if (value.mode !== 'observe' && value.mode !== 'steer') throw new Error('Invalid self-check settings');
@@ -71,6 +71,27 @@ function validCheck(value) {
71
71
  if (!Number.isSafeInteger(value.lowScoreThreshold) || value.lowScoreThreshold < 0 || value.lowScoreThreshold > 9) throw new Error('Invalid self-check settings');
72
72
  check.lowScoreThreshold = value.lowScoreThreshold;
73
73
  }
74
+ // Quality-trend alarm thresholds (all unvalidated heuristics, tuned on real logs):
75
+ // a high-confidence low score requires confidence ≥ trendMinConfidence; a run of
76
+ // trendRun such scores raises "warn", trendSevereRun raises "severe".
77
+ if (value.trendMinConfidence !== undefined) {
78
+ if (typeof value.trendMinConfidence !== 'number' || !Number.isFinite(value.trendMinConfidence) || value.trendMinConfidence < 0 || value.trendMinConfidence > 1) throw new Error('Invalid self-check settings');
79
+ check.trendMinConfidence = value.trendMinConfidence;
80
+ }
81
+ if (value.trendRun !== undefined) {
82
+ if (!Number.isSafeInteger(value.trendRun) || value.trendRun < 2 || value.trendRun > 10) throw new Error('Invalid self-check settings');
83
+ check.trendRun = value.trendRun;
84
+ }
85
+ if (value.trendSevereRun !== undefined) {
86
+ if (!Number.isSafeInteger(value.trendSevereRun) || value.trendSevereRun < 2 || value.trendSevereRun > 20) throw new Error('Invalid self-check settings');
87
+ check.trendSevereRun = value.trendSevereRun;
88
+ }
89
+ // The severe threshold must sit strictly above the warn threshold; check against
90
+ // the effective warn value (explicit or default 3) so the pair is always ordered.
91
+ if (check.trendSevereRun !== undefined) {
92
+ const warn = check.trendRun ?? 3;
93
+ if (check.trendSevereRun <= warn) throw new Error('Invalid self-check settings');
94
+ }
74
95
  return check;
75
96
  }
76
97
 
@@ -106,9 +127,27 @@ function validNarrow(value) {
106
127
  return narrow;
107
128
  }
108
129
 
109
- // Per-feature enable flags for the three decision points. Every flag defaults to
130
+ // Per-feature enable flags for the decision points. Every flag defaults to
110
131
  // enabled; only an explicit `false` disables its decision point.
111
- export const FEATURE_KEYS = Object.freeze(['gate', 'check', 'narrow']);
132
+ export const FEATURE_KEYS = Object.freeze(['gate', 'check', 'narrow', 'complete']);
133
+
134
+ // Task-completion checking settings: observe-only by default; steer opts into a
135
+ // single supplemental prompt on low completion. minConditions is the minimum
136
+ // number of explicit conditions a turn must have for the check to run at all.
137
+ function validComplete(value) {
138
+ if (!safeObject(value)) return undefined;
139
+ if (Object.keys(value).some(key => !['mode', 'minConditions'].includes(key))) throw new Error('Invalid task-completion settings');
140
+ const complete = {};
141
+ if (value.mode !== undefined) {
142
+ if (value.mode !== 'observe' && value.mode !== 'steer') throw new Error('Invalid task-completion settings');
143
+ complete.mode = value.mode;
144
+ }
145
+ if (value.minConditions !== undefined) {
146
+ if (!Number.isSafeInteger(value.minConditions) || value.minConditions < 2 || value.minConditions > 20) throw new Error('Invalid task-completion settings');
147
+ complete.minConditions = value.minConditions;
148
+ }
149
+ return complete;
150
+ }
112
151
 
113
152
  function validFeatures(value) {
114
153
  if (!safeObject(value)) return undefined;
@@ -138,6 +177,7 @@ function view(stored) {
138
177
  if (stored.dangerRules !== undefined) result.dangerRules = validRules(stored.dangerRules);
139
178
  if (stored.checkSettings !== undefined) result.checkSettings = validCheck(stored.checkSettings);
140
179
  if (stored.narrowSettings !== undefined) result.narrowSettings = validNarrow(stored.narrowSettings);
180
+ if (stored.completeSettings !== undefined) result.completeSettings = validComplete(stored.completeSettings);
141
181
  if (stored.features !== undefined) result.features = validFeatures(stored.features);
142
182
  return result;
143
183
  }
@@ -158,12 +198,13 @@ export async function resolveConfig({ path = configPath(), env = process.env } =
158
198
  if (stored.dangerRules !== undefined) resolved.dangerRules = validRules(stored.dangerRules);
159
199
  if (stored.checkSettings !== undefined) resolved.checkSettings = validCheck(stored.checkSettings);
160
200
  if (stored.narrowSettings !== undefined) resolved.narrowSettings = validNarrow(stored.narrowSettings);
201
+ if (stored.completeSettings !== undefined) resolved.completeSettings = validComplete(stored.completeSettings);
161
202
  if (stored.features !== undefined) resolved.features = validFeatures(stored.features);
162
203
  return resolved;
163
204
  }
164
205
 
165
206
  export async function saveConfig(input, path = configPath()) {
166
- if (!safeObject(input) || Object.keys(input).some(key => !['url', 'apiKey', 'model', 'confirmHttpUrl', 'dangerRules', 'checkSettings', 'narrowSettings', 'features'].includes(key))) {
207
+ if (!safeObject(input) || Object.keys(input).some(key => !['url', 'apiKey', 'model', 'confirmHttpUrl', 'dangerRules', 'checkSettings', 'narrowSettings', 'completeSettings', 'features'].includes(key))) {
167
208
  throw new Error('Invalid configuration fields');
168
209
  }
169
210
  for (const key of ['url', 'apiKey', 'model', 'confirmHttpUrl']) {
@@ -179,6 +220,7 @@ export async function saveConfig(input, path = configPath()) {
179
220
  const dangerRules = input.dangerRules === undefined ? previous.dangerRules : validRules(input.dangerRules);
180
221
  const checkSettings = input.checkSettings === undefined ? previous.checkSettings : validCheck(input.checkSettings);
181
222
  const narrowSettings = input.narrowSettings === undefined ? previous.narrowSettings : validNarrow(input.narrowSettings);
223
+ const completeSettings = input.completeSettings === undefined ? previous.completeSettings : validComplete(input.completeSettings);
182
224
  const features = input.features === undefined ? previous.features : validFeatures(input.features);
183
225
  const confirmed = url.startsWith('http://') && input.confirmHttpUrl !== undefined &&
184
226
  checkedUrl(safeText(input.confirmHttpUrl)) === url;
@@ -189,6 +231,7 @@ export async function saveConfig(input, path = configPath()) {
189
231
  if (dangerRules !== undefined) next.dangerRules = dangerRules;
190
232
  if (checkSettings !== undefined) next.checkSettings = checkSettings;
191
233
  if (narrowSettings !== undefined) next.narrowSettings = narrowSettings;
234
+ if (completeSettings !== undefined) next.completeSettings = completeSettings;
192
235
  if (features !== undefined) next.features = features;
193
236
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
194
237
  const temporary = `${path}.${randomUUID()}.tmp`;
@@ -92,10 +92,19 @@ export function createDangerGate({ evaluate, sessions, rules = DEFAULT_DANGEROUS
92
92
  try { sessions.log(id, { kind: 'gate', outcome, tool: exec?.name, ...detail }); }
93
93
  catch { /* logging is best effort */ }
94
94
  };
95
- const fallback = (exec, reason = 'unavailable', detail = {}) => {
96
- record(exec, 'error', { action: 'ask', reason, ...detail });
95
+ // Two-tier model: the gate intervenes ONLY on a high-confidence `deny`
96
+ // (returned as kind:'deny', enforced monotonically through tools.guard,
97
+ // independent of the host approval policy). Every other outcome — a low-
98
+ // confidence verdict, a high-confidence allow/ask, an invalid response, or a
99
+ // backend failure — is NOT an intervention: the call proceeds on its original
100
+ // path (next()). The gate no longer emits `ask`, so it never routes through
101
+ // host approval and can no longer be mis-rendered as "the user rejected".
102
+ // `passThrough` records the non-intervention for observability and returns
103
+ // undefined so preExecute falls through to next().
104
+ const passThrough = (exec, outcome, detail = {}) => {
105
+ record(exec, outcome, { action: 'pass', ...detail });
97
106
  if (exec && typeof exec === 'object') evaluatedCalls.add(exec);
98
- return { kind: 'ask', reason: 'Dangerous call requires host approval' };
107
+ return undefined;
99
108
  };
100
109
  return {
101
110
  classify: exec => classifyDangerous(exec, typeof rules === 'function' ? DEFAULT_DANGEROUS_RULES : rules),
@@ -112,18 +121,27 @@ export function createDangerGate({ evaluate, sessions, rules = DEFAULT_DANGEROUS
112
121
  evaluatedCalls.delete(exec);
113
122
  },
114
123
  async evaluate(exec) {
115
- if (exec && typeof exec === 'object') evaluatedCalls.add(exec);
116
124
  let result;
117
125
  try {
118
126
  result = await evaluate(suggestionQuestion(exec), { signal: exec.signal });
119
- } catch (error) { return fallback(exec, typeof error?.reason === 'string' ? error.reason : 'unreachable'); }
127
+ } catch (error) {
128
+ // Backend failure: no verdict, so do not intervene (pass through).
129
+ return passThrough(exec, 'error', { reason: typeof error?.reason === 'string' ? error.reason : 'unreachable' });
130
+ }
120
131
  const answer = result?.answers?.verdict;
121
132
  const choice = answer?.choice;
122
- if (!['allow', 'ask', 'deny'].includes(choice)) return fallback(exec, 'invalid-response');
123
- if (typeof answer.confidence !== 'number' || answer.confidence < minConfidence) return fallback(exec, 'low-confidence', typeof answer.confidence === 'number' && Number.isFinite(answer.confidence) ? { confidence: answer.confidence } : {});
124
- if (choice === 'deny') { record(exec, 'deny', { suggestion: 'deny', action: 'deny' }); if (exec && typeof exec === 'object') deniedCalls.add(exec); return { kind: 'deny', reason: 'Dangerous call rejected by decision policy', modelSuggestion: choice }; }
125
- record(exec, choice, { suggestion: choice, action: 'ask' });
126
- return { kind: 'ask', reason: 'Dangerous call requires host approval', modelSuggestion: choice };
133
+ if (!['allow', 'ask', 'deny'].includes(choice)) return passThrough(exec, 'error', { reason: 'invalid-response' });
134
+ const confident = typeof answer.confidence === 'number' && Number.isFinite(answer.confidence) && answer.confidence >= minConfidence;
135
+ // Only a high-confidence deny blocks the call.
136
+ if (choice === 'deny' && confident) {
137
+ record(exec, 'deny', { suggestion: 'deny', action: 'deny' });
138
+ if (exec && typeof exec === 'object') deniedCalls.add(exec);
139
+ return { kind: 'deny', reason: 'Dangerous call rejected by decision policy', modelSuggestion: choice };
140
+ }
141
+ // Uncertain (low confidence) or a non-deny verdict → do not intervene.
142
+ const detail = { suggestion: choice };
143
+ if (!confident) { detail.reason = 'low-confidence'; if (typeof answer.confidence === 'number' && Number.isFinite(answer.confidence)) detail.confidence = answer.confidence; }
144
+ return passThrough(exec, 'allow', detail);
127
145
  },
128
146
  async preExecute(exec, next) {
129
147
  const classification = classifyDangerous(exec, await resolveRules());
@@ -131,13 +149,12 @@ export function createDangerGate({ evaluate, sessions, rules = DEFAULT_DANGEROUS
131
149
  const id = exec?.agent?.session?.id;
132
150
  if (sessions && typeof id === 'string') {
133
151
  try {
134
- const snapshot = sessions.snapshot(id);
135
- // A deliberate user toggle bypasses the gate; involuntary capacity overflow must not fail-open.
136
- if (snapshot.enabled === false && !snapshot.capacityExceeded) return next();
137
- if (snapshot.capacityExceeded) return fallback(exec, 'capacity');
138
- } catch { return fallback(exec, 'unreachable'); }
152
+ // A deliberate user toggle bypasses the gate entirely (no record).
153
+ if (sessions.snapshot(id).enabled === false) return next();
154
+ } catch { passThrough(exec, 'error', { reason: 'unreachable' }); return next(); }
139
155
  }
140
- return this.evaluate(exec);
156
+ const decision = await this.evaluate(exec);
157
+ return decision ?? next();
141
158
  },
142
159
  };
143
160
  }
package/service/dsh.mjs CHANGED
@@ -7,8 +7,10 @@ import { createSessionState } from './session-state.mjs';
7
7
  import { createDangerGate, normalizeDangerRules } from './danger-gate.mjs';
8
8
  import { createSelfCheck } from './self-check.mjs';
9
9
  import { createNarrowing } from './narrowing.mjs';
10
+ import { createTaskCompletion } from './task-completion.mjs';
10
11
  import { createLoopGuard } from './loop-guard.mjs';
11
12
  import { createLogStore } from './log-store.mjs';
13
+ import { analyzeSessionLogs } from './log-analyze.mjs';
12
14
 
13
15
  export const name = 'dsh-decision-layer';
14
16
  export const inject = ['tools', 'skills'];
@@ -40,6 +42,47 @@ function stepTaskText(messages) {
40
42
  } catch { return ''; }
41
43
  }
42
44
 
45
+ // The user's request text for this turn, used by task-completion to extract the
46
+ // explicit conditions. Best-effort: an unreachable shape yields '', which skips
47
+ // the check. Takes the trailing run of user messages (the prompt entering the turn).
48
+ function turnUserText(agent) {
49
+ try {
50
+ const messages = agent?.session?.deriveMessages?.();
51
+ if (!Array.isArray(messages)) return '';
52
+ const texts = [];
53
+ let collecting = false;
54
+ // Walk backwards: skip the turn's trailing assistant/tool messages, collect the
55
+ // contiguous user run that started this turn, then stop at the prior assistant.
56
+ for (let i = messages.length - 1; i >= 0; i--) {
57
+ const message = messages[i];
58
+ if (message?.role === 'user' && Array.isArray(message.content)) {
59
+ collecting = true;
60
+ texts.unshift(message.content.filter(block => block?.type === 'text' && typeof block.text === 'string').map(block => block.text).join('\n'));
61
+ } else if (collecting) break;
62
+ }
63
+ return texts.join('\n').slice(0, 8 * 1024);
64
+ } catch { return ''; }
65
+ }
66
+
67
+ // Tool evidence for the turn's task-completion check: the tool calls and their
68
+ // ok/error outcome from the derived history. Names only (no arguments), bounded.
69
+ // An unreachable shape yields [], which skips the check.
70
+ function turnToolEvidence(agent) {
71
+ try {
72
+ const messages = agent?.session?.deriveMessages?.();
73
+ if (!Array.isArray(messages)) return [];
74
+ const evidence = [];
75
+ for (const message of messages) {
76
+ if (message?.role !== 'tool' || !Array.isArray(message.content)) continue;
77
+ const tool = typeof message.name === 'string' ? message.name : (typeof message.toolName === 'string' ? message.toolName : '');
78
+ if (!tool) continue;
79
+ evidence.push({ tool: tool.slice(0, 128), outcome: message.isError === true ? 'error' : 'ok' });
80
+ if (evidence.length >= 40) break;
81
+ }
82
+ return evidence;
83
+ } catch { return []; }
84
+ }
85
+
43
86
  // Enumerate the tool names/descriptions visible to this agent scope, best-effort.
44
87
  function agentTools(ctx, agent) {
45
88
  try {
@@ -106,6 +149,29 @@ export async function apply(ctx, options = {}) {
106
149
  if (typeof ctx.effect === 'function') ctx.effect(installCheck);
107
150
  else installCheck();
108
151
 
152
+ const taskCompletion = createTaskCompletion({
153
+ evaluate: (input, request) => backend.evaluate(input, request),
154
+ sessions,
155
+ settings: async () => (await resolveConfig({ path })).completeSettings,
156
+ steer: (agent, text) => agent.steer(createUserMessage({
157
+ content: [{ type: 'text', text }],
158
+ source: { kind: 'user' },
159
+ })),
160
+ });
161
+ // Independent turn-stopping listener, separate from the self-check: it checks
162
+ // whether the user's explicit conditions have supporting tool evidence.
163
+ const installCompletion = () => ctx.on('agent/turn-stopping', async payload => {
164
+ if (!(await featureOn('complete'))) return;
165
+ return taskCompletion.review({
166
+ agent: payload.agent, turn: payload.turn, signal: payload.signal,
167
+ output: lastAssistantText(payload.agent),
168
+ userText: turnUserText(payload.agent),
169
+ evidence: turnToolEvidence(payload.agent),
170
+ });
171
+ });
172
+ if (typeof ctx.effect === 'function') ctx.effect(installCompletion);
173
+ else installCompletion();
174
+
109
175
  const narrowing = createNarrowing({
110
176
  evaluate: (input, request) => backend.evaluate(input, request),
111
177
  sessions,
@@ -169,7 +235,12 @@ export async function apply(ctx, options = {}) {
169
235
 
170
236
  ctx.inject(['webServer'], web => {
171
237
  logStore = createLogStore({ port: web.webServer.port });
172
- const unregister = createManagerRoutes({ path, sessions, backend, bindHost: web.webServer.host }).map(route => web.webServer.register(route));
238
+ const port = web.webServer.port;
239
+ const analyze = async sessionId => analyzeSessionLogs(sessionId, {
240
+ port,
241
+ trendConfig: (await resolveConfig({ path })).checkSettings,
242
+ });
243
+ const unregister = createManagerRoutes({ path, sessions, backend, logStore, analyze, bindHost: web.webServer.host }).map(route => web.webServer.register(route));
173
244
  return () => { unregister.forEach(dispose => dispose()); const store = logStore; logStore = undefined; void store?.dispose(); };
174
245
  });
175
246
  }
@@ -0,0 +1,155 @@
1
+ import { createReadableStream } from './log-read.mjs';
2
+
3
+ // Offline analysis over the persisted decision JSONL logs. Pure aggregation:
4
+ // given a stream of records (decision events + annotation events) for one
5
+ // session, fold annotations onto decisions by id (latest wins) and build a
6
+ // process-metrics profile plus a quality-trend threshold backtest.
7
+ //
8
+ // Reading is separated (log-read.mjs) so this module stays pure and testable
9
+ // without touching the filesystem.
10
+
11
+ const DECISION_KINDS = new Set(['gate', 'check', 'narrow', 'complete']);
12
+ const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
13
+
14
+ // Reduce one session's records into decisions (with the latest annotation
15
+ // attached) and per-kind aggregates. `records` is any iterable of parsed rows.
16
+ export function foldSession(records) {
17
+ const decisions = new Map(); // id → decision record
18
+ const annotations = new Map(); // target id → latest annotation
19
+ for (const record of records) {
20
+ if (!isObject(record)) continue;
21
+ if (record.kind === 'annotation') {
22
+ const target = typeof record.target === 'string' ? record.target : undefined;
23
+ if (!target) continue;
24
+ const prev = annotations.get(target);
25
+ if (!prev || (Number.isFinite(record.at) && record.at >= (prev.at ?? 0))) {
26
+ annotations.set(target, { rating: record.rating, at: Number.isFinite(record.at) ? record.at : 0 });
27
+ }
28
+ continue;
29
+ }
30
+ if (DECISION_KINDS.has(record.kind) && typeof record.id === 'string') {
31
+ decisions.set(record.id, record);
32
+ }
33
+ }
34
+ return { decisions, annotations };
35
+ }
36
+
37
+ // A confidence bucket label for the [0,1] range, coarse enough to be useful.
38
+ function confidenceBucket(value) {
39
+ if (typeof value !== 'number' || !Number.isFinite(value)) return undefined;
40
+ if (value < 0.4) return 'lt40';
41
+ if (value < 0.6) return 'lt60';
42
+ if (value < 0.8) return 'lt80';
43
+ return 'gte80';
44
+ }
45
+
46
+ function emptyProfile() {
47
+ return {
48
+ gate: { attempts: 0, allow: 0, ask: 0, deny: 0, error: 0 },
49
+ check: { attempts: 0, ok: 0, low: 0, error: 0, scoreSum: 0, scoreCount: 0, confidence: { lt40: 0, lt60: 0, lt80: 0, gte80: 0 } },
50
+ narrow: { attempts: 0, applied: 0, error: 0, droppedSum: 0, tooManyCandidates: 0 },
51
+ complete: { attempts: 0, satisfied: 0, unsatisfied: 0, insufficient: 0, error: 0, steered: 0 },
52
+ };
53
+ }
54
+
55
+ // Build the process-metrics profile from the folded decisions. Clean denominators:
56
+ // each `attempts` counts real evaluations of that kind; averages (e.g. mean
57
+ // dropped) are reported as-is, never mislabeled as percentages.
58
+ export function buildProfile(decisions) {
59
+ const profile = emptyProfile();
60
+ for (const record of decisions.values()) {
61
+ const kind = record.kind;
62
+ const bucket = profile[kind];
63
+ if (!bucket) continue;
64
+ bucket.attempts++;
65
+ if (kind === 'gate') {
66
+ if (record.outcome === 'error') bucket.error++;
67
+ else if (['allow', 'ask', 'deny'].includes(record.outcome)) bucket[record.outcome]++;
68
+ } else if (kind === 'check') {
69
+ if (record.outcome === 'error') bucket.error++;
70
+ else if (record.outcome === 'low') bucket.low++;
71
+ else bucket.ok++;
72
+ if (typeof record.score === 'number' && Number.isFinite(record.score)) { bucket.scoreSum += record.score; bucket.scoreCount++; }
73
+ const cb = confidenceBucket(record.confidence);
74
+ if (cb) bucket.confidence[cb]++;
75
+ } else if (kind === 'narrow') {
76
+ if (record.outcome === 'error') bucket.error++;
77
+ else if (record.outcome === 'applied') bucket.applied++;
78
+ if (Number.isSafeInteger(record.dropped) && record.dropped > 0) bucket.droppedSum += record.dropped;
79
+ if (record.reason === 'too-many-candidates') bucket.tooManyCandidates++;
80
+ } else if (kind === 'complete') {
81
+ if (record.outcome === 'error') bucket.error++;
82
+ if (Number.isSafeInteger(record.satisfied)) bucket.satisfied += record.satisfied;
83
+ if (Number.isSafeInteger(record.unsatisfied)) bucket.unsatisfied += record.unsatisfied;
84
+ if (Number.isSafeInteger(record.insufficient)) bucket.insufficient += record.insufficient;
85
+ if (record.steered === true) bucket.steered++;
86
+ }
87
+ }
88
+ return profile;
89
+ }
90
+
91
+ // Replay the quality-trend thresholds over this session's check records in time
92
+ // order: how many times would the alarm have fired at warn / severe? Uses the
93
+ // same run semantics as quality-trend.mjs (good resets, high-confidence low
94
+ // grows, low-confidence low and non-low are skipped for the run — but a good
95
+ // score still resets). This is a calibration aid, not a live signal.
96
+ export function backtestTrend(checkRecords, { trendMinConfidence = 0.6, trendRun = 3, trendSevereRun = 5, lowScoreThreshold = 1 } = {}) {
97
+ const ordered = [...checkRecords].filter(r => isObject(r) && r.kind === 'check').sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
98
+ let run = 0, warnHits = 0, severeHits = 0, maxRun = 0;
99
+ for (const record of ordered) {
100
+ if (record.outcome === 'error') continue; // unevaluated: skip
101
+ const isLow = record.outcome === 'low' || (typeof record.score === 'number' && record.score <= lowScoreThreshold);
102
+ if (!isLow) { run = 0; continue; }
103
+ const confident = typeof record.confidence === 'number' && Number.isFinite(record.confidence) && record.confidence >= trendMinConfidence;
104
+ if (!confident) continue; // low-confidence low: skip, run unchanged
105
+ run++;
106
+ maxRun = Math.max(maxRun, run);
107
+ if (run === trendSevereRun) severeHits++;
108
+ else if (run === trendRun) warnHits++;
109
+ }
110
+ return { warnHits, severeHits, maxRun };
111
+ }
112
+
113
+ // Attach annotation summary: how many decisions carry a good/bad/unsure rating.
114
+ // Also returns a per-decision ratings map (id → rating) so a UI can show the
115
+ // persisted annotation state on each decision, whichever surface renders it.
116
+ export function annotationSummary(decisions, annotations) {
117
+ const summary = { rated: 0, good: 0, bad: 0, unsure: 0 };
118
+ const ratings = Object.create(null);
119
+ for (const [id] of decisions) {
120
+ const annotation = annotations.get(id);
121
+ if (!annotation || typeof annotation.rating !== 'string') continue;
122
+ summary.rated++;
123
+ if (annotation.rating === 'good') summary.good++;
124
+ else if (annotation.rating === 'bad') summary.bad++;
125
+ else if (annotation.rating === 'unsure') summary.unsure++;
126
+ ratings[id] = annotation.rating;
127
+ }
128
+ return { summary, ratings };
129
+ }
130
+
131
+ // One-call analysis over an iterable of records for a single session.
132
+ export function analyzeSession(records, trendConfig) {
133
+ const { decisions, annotations } = foldSession(records);
134
+ const annotation = annotationSummary(decisions, annotations);
135
+ return {
136
+ totalDecisions: decisions.size,
137
+ profile: buildProfile(decisions),
138
+ trendBacktest: backtestTrend([...decisions.values()], trendConfig),
139
+ annotations: annotation.summary,
140
+ ratings: annotation.ratings,
141
+ };
142
+ }
143
+
144
+ // Read the persisted logs for one session id and analyze them. Streams files so
145
+ // a large corpus is never fully buffered; only rows whose sessionId matches are
146
+ // kept. `readStream` is injected for tests; production reads the logs dir.
147
+ export async function analyzeSessionLogs(sessionId, { dir, port, trendConfig, readStream = createReadableStream } = {}) {
148
+ if (typeof sessionId !== 'string' || !sessionId.trim()) throw new Error('Invalid session id');
149
+ const records = [];
150
+ for await (const record of readStream({ dir, port })) {
151
+ if (!isObject(record)) continue;
152
+ if (record.sessionId === sessionId) records.push(record);
153
+ }
154
+ return analyzeSession(records, trendConfig);
155
+ }
@@ -0,0 +1,27 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { readdir } from 'node:fs/promises';
3
+ import { createInterface } from 'node:readline';
4
+ import { join } from 'node:path';
5
+ import { logsDir } from './log-store.mjs';
6
+
7
+ // Stream the persisted decision JSONL logs line by line, yielding one parsed
8
+ // record per line. Files are read sequentially so a large corpus is never fully
9
+ // buffered. A malformed line is skipped, not fatal. Only this port's files are
10
+ // read when `port` is given (matching the log-store naming); otherwise all
11
+ // `decisions-*.jsonl` files are read.
12
+ export async function* createReadableStream({ dir = logsDir(), port } = {}) {
13
+ const prefix = Number.isInteger(port) && port > 0 ? `decisions-${port}-` : 'decisions-';
14
+ let names;
15
+ try { names = await readdir(dir); } catch { return; }
16
+ const files = names.filter(name => name.startsWith(prefix) && name.endsWith('.jsonl')).sort();
17
+ for (const name of files) {
18
+ const stream = createReadStream(join(dir, name), { encoding: 'utf8' });
19
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
20
+ try {
21
+ for await (const line of lines) {
22
+ if (!line.trim()) continue;
23
+ try { yield JSON.parse(line); } catch { /* skip malformed line */ }
24
+ }
25
+ } finally { lines.close(); stream.close(); }
26
+ }
27
+ }
@@ -61,6 +61,18 @@ export function createLogStore({ port, dir = logsDir(), flushIntervalMs = 5000,
61
61
  if (buffer.length >= maxBuffer) { void flush(); return; }
62
62
  if (!timer) timer = setTimeout(() => { timer = undefined; void flush(); }, flushIntervalMs);
63
63
  },
64
+ // An annotation is an explicit user action, not telemetry: write it
65
+ // immediately (not batched) and surface failure to the caller instead of
66
+ // dropping it. It lands in the same JSONL directory as a `kind:"annotation"`
67
+ // event and is pruned with the decision logs on the next flush.
68
+ async appendAnnotation(entry) {
69
+ if (entry === null || typeof entry !== 'object') throw new Error('Invalid annotation');
70
+ const at = Number.isFinite(entry.at) ? entry.at : now().getTime();
71
+ const record = { ...entry, at };
72
+ await mkdir(dir, { recursive: true, mode: 0o700 });
73
+ await appendFile(fileFor(new Date(at)), `${JSON.stringify(record)}\n`, { mode: 0o600 });
74
+ return record;
75
+ },
64
76
  flush,
65
77
  dispose() {
66
78
  if (timer) { clearTimeout(timer); timer = undefined; }
@@ -28,12 +28,11 @@ export function createLoopGuard({ threshold = 3, maxSessions = 1024, sessions }
28
28
  check(exec) {
29
29
  const id = exec?.agent?.session?.id;
30
30
  if (typeof id !== 'string') return undefined;
31
- // The master switch pauses automatic intervention (mirrors the danger gate);
32
- // capacity overflow still guards, since that is involuntary, not a user choice.
31
+ // The master switch pauses automatic intervention (mirrors the danger gate):
32
+ // a deliberate user toggle skips the loop guard too.
33
33
  if (sessions) {
34
34
  try {
35
- const snapshot = sessions.snapshot(id);
36
- if (snapshot.enabled === false && !snapshot.capacityExceeded) return undefined;
35
+ if (sessions.snapshot(id).enabled === false) return undefined;
37
36
  } catch { /* fall through and guard */ }
38
37
  }
39
38
  const run = runFor(id);
@@ -37,7 +37,7 @@ async function readBody(req) {
37
37
  return value;
38
38
  }
39
39
 
40
- export function createManagerRoutes({ path, sessions, backend, env = process.env, bindHost = '127.0.0.1' }) {
40
+ export function createManagerRoutes({ path, sessions, backend, logStore, analyze, env = process.env, bindHost = '127.0.0.1' }) {
41
41
  const route = (suffix, methods) => ({ kind: 'exact', path: `${API_PREFIX}/${suffix}`, handler: async (req, res) => {
42
42
  if (bindHost !== '127.0.0.1' || !authorizedBrowser(req)) return respond(res, 403, { ok: false, error: 'Local same-origin browser request required' });
43
43
  if (!Object.hasOwn(methods, req.method)) return respond(res, 405, { ok: false, error: 'Method not allowed' });
@@ -75,6 +75,31 @@ export function createManagerRoutes({ path, sessions, backend, env = process.env
75
75
  }),
76
76
  route('metrics', { GET: req => sessions.snapshot(sessionId(req)) }),
77
77
  route('log', { GET: req => ({ entries: sessions.snapshot(sessionId(req)).log ?? [] }) }),
78
+ // Read-only analysis over the persisted decision logs for one session. Returns
79
+ // only aggregates and annotation counts — never raw log rows — so gate command
80
+ // / path fragments are not echoed back to the browser.
81
+ route('logs', {
82
+ GET: async req => {
83
+ if (typeof analyze !== 'function') { const error = new Error('analysis unavailable'); error.code = 'BAD_REQUEST'; throw error; }
84
+ return analyze(sessionId(req));
85
+ },
86
+ }),
87
+ // Append one annotation event for a decision. Explicit user intent: validated
88
+ // and written immediately; a write failure surfaces as an error, not silence.
89
+ route('annotate', {
90
+ POST: async req => {
91
+ if (!logStore || typeof logStore.appendAnnotation !== 'function') { const error = new Error('annotation unavailable'); error.code = 'BAD_REQUEST'; throw error; }
92
+ const body = await readBody(req);
93
+ const target = typeof body.target === 'string' ? body.target.trim() : '';
94
+ const rating = body.rating;
95
+ const session = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
96
+ if (!target || target.length > 128 || !['good', 'bad', 'unsure'].includes(rating) || !session || session.length > 512) {
97
+ const error = new Error('Invalid annotation'); error.code = 'BAD_REQUEST'; throw error;
98
+ }
99
+ await logStore.appendAnnotation({ kind: 'annotation', target, rating, sessionId: session });
100
+ return { ok: true, target, rating };
101
+ },
102
+ }),
78
103
  route('session', {
79
104
  GET: req => sessions.snapshot(sessionId(req)),
80
105
  PUT: async req => {
@@ -0,0 +1,29 @@
1
+ // Pure helpers for the output-quality trend alarm. A run counts consecutive
2
+ // high-confidence low self-check scores; the run length maps to a severity that
3
+ // the trigger icon renders (normal → warn → severe). All thresholds are
4
+ // unvalidated heuristics, tuned later on real decision logs.
5
+
6
+ export const DEFAULT_TREND_MIN_CONFIDENCE = 0.6;
7
+ export const DEFAULT_TREND_RUN = 3;
8
+ export const DEFAULT_TREND_SEVERE_RUN = 5;
9
+
10
+ // Advance the run over ONE evaluated self-check:
11
+ // - a good score (not low) resets the run to 0;
12
+ // - a high-confidence low score increments it;
13
+ // - a low-confidence low score is skipped (neither resets nor increments);
14
+ // - an unevaluated/absent verdict must not call this at all.
15
+ // `sample` is { lowScore: boolean, confident: boolean }.
16
+ export function advanceRun(run, sample) {
17
+ const current = Number.isSafeInteger(run) && run >= 0 ? run : 0;
18
+ if (!sample || sample.lowScore !== true) return 0;
19
+ if (sample.confident === true) return current + 1;
20
+ return current;
21
+ }
22
+
23
+ // Map a run length to a severity, given the warn/severe thresholds.
24
+ export function severityOf(run, { trendRun = DEFAULT_TREND_RUN, trendSevereRun = DEFAULT_TREND_SEVERE_RUN } = {}) {
25
+ const value = Number.isSafeInteger(run) && run >= 0 ? run : 0;
26
+ if (value >= trendSevereRun) return 'severe';
27
+ if (value >= trendRun) return 'warn';
28
+ return 'normal';
29
+ }
@@ -40,12 +40,16 @@ export function createSelfCheck({ evaluate, sessions, steer, settings, minConfid
40
40
  try { raw = (typeof settings === 'function' ? await settings() : settings) ?? {}; } catch { raw = {}; }
41
41
  const mode = raw?.mode === 'steer' ? 'steer' : 'observe';
42
42
  const lowScoreThreshold = Number.isSafeInteger(raw?.lowScoreThreshold) && raw.lowScoreThreshold >= 0 ? raw.lowScoreThreshold : 1;
43
- return { mode, rubric: boundedRubric(raw?.rubric), lowScoreThreshold };
43
+ // Quality-trend thresholds (unvalidated heuristics; see quality-trend.mjs).
44
+ const trendMinConfidence = typeof raw?.trendMinConfidence === 'number' && Number.isFinite(raw.trendMinConfidence) && raw.trendMinConfidence >= 0 && raw.trendMinConfidence <= 1 ? raw.trendMinConfidence : 0.6;
45
+ const trendRun = Number.isSafeInteger(raw?.trendRun) && raw.trendRun >= 2 ? raw.trendRun : 3;
46
+ const trendSevereRun = Number.isSafeInteger(raw?.trendSevereRun) && raw.trendSevereRun > trendRun ? raw.trendSevereRun : Math.max(trendRun + 1, 5);
47
+ return { mode, rubric: boundedRubric(raw?.rubric), lowScoreThreshold, trendMinConfidence, trendRun, trendSevereRun };
44
48
  };
45
- const record = (turn, outcome, detail = {}) => {
49
+ const record = (turn, outcome, detail = {}, trend) => {
46
50
  const id = turn?.agent?.session?.id;
47
51
  if (!sessions || typeof id !== 'string' || typeof sessions.recordCheck !== 'function') return;
48
- try { sessions.recordCheck(id, outcome); } catch { /* telemetry is best effort */ }
52
+ try { sessions.recordCheck(id, outcome, trend); } catch { /* telemetry is best effort */ }
49
53
  if (typeof sessions.log !== 'function') return;
50
54
  try { sessions.log(id, { kind: 'check', outcome, ...detail }); }
51
55
  catch { /* logging is best effort */ }
@@ -59,7 +63,7 @@ export function createSelfCheck({ evaluate, sessions, steer, settings, minConfid
59
63
  try { if (sessions.snapshot(id).enabled === false) return { evaluated: false, lowScore: false, steered: false }; }
60
64
  catch { return { evaluated: false, lowScore: false, steered: false }; }
61
65
  }
62
- const { mode, rubric, lowScoreThreshold } = await resolveSettings();
66
+ const { mode, rubric, lowScoreThreshold, trendMinConfidence, trendRun, trendSevereRun } = await resolveSettings();
63
67
  let answer;
64
68
  try {
65
69
  const result = await evaluate(scoreQuestion(output, rubric), { signal: turn.signal });
@@ -75,7 +79,10 @@ export function createSelfCheck({ evaluate, sessions, steer, settings, minConfid
75
79
  const confidence = typeof answer.confidence === 'number' && Number.isFinite(answer.confidence) ? answer.confidence : undefined;
76
80
  const detail = confidence === undefined ? {} : { confidence };
77
81
  const lowScore = answer.score <= lowScoreThreshold;
78
- record(turn, lowScore ? 'low' : 'ok', { score: answer.score, ...detail });
82
+ // The trend run advances over every evaluated score; a high-confidence low
83
+ // score is the only thing that grows it. Thresholds flow to session-state.
84
+ const trend = { confident: lowScore && confidence !== undefined && confidence >= trendMinConfidence, trendRun, trendSevereRun };
85
+ record(turn, lowScore ? 'low' : 'ok', { score: answer.score, ...detail }, trend);
79
86
  if (!lowScore) return { evaluated: true, lowScore: false, steered: false, score: answer.score };
80
87
  const confident = confidence !== undefined && confidence >= minConfidence;
81
88
  if (mode === 'steer' && confident && typeof steer === 'function' && turn?.agent) {