dsh-decision-layer 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.en.md +14 -14
  2. package/README.md +16 -16
  3. package/docs/en/DETAIL.md +2 -2
  4. package/docs/zh/DETAIL.md +2 -2
  5. package/lib/client.js +54 -23
  6. package/lib/host/backend.d.ts +15 -0
  7. package/lib/host/backend.js +149 -0
  8. package/lib/host/config-store.d.ts +12 -0
  9. package/lib/host/config-store.js +254 -0
  10. package/lib/host/danger-gate.d.ts +45 -0
  11. package/lib/host/danger-gate.js +196 -0
  12. package/lib/host/decision-context.d.ts +21 -0
  13. package/lib/host/decision-context.js +187 -0
  14. package/lib/host/excerpt.d.ts +26 -0
  15. package/lib/host/excerpt.js +76 -0
  16. package/lib/host/index.d.ts +6 -0
  17. package/lib/host/index.js +287 -0
  18. package/lib/host/log-analyze.d.ts +121 -0
  19. package/lib/host/log-analyze.js +254 -0
  20. package/lib/host/log-read.d.ts +4 -0
  21. package/lib/host/log-read.js +34 -0
  22. package/lib/host/log-store.d.ts +15 -0
  23. package/lib/host/log-store.js +98 -0
  24. package/lib/host/loop-guard.d.ts +10 -0
  25. package/lib/host/loop-guard.js +52 -0
  26. package/lib/host/manager-api.d.ts +20 -0
  27. package/lib/host/manager-api.js +174 -0
  28. package/lib/host/narrowing.d.ts +51 -0
  29. package/lib/host/narrowing.js +214 -0
  30. package/lib/host/quality-trend.d.ts +14 -0
  31. package/lib/host/quality-trend.js +22 -0
  32. package/lib/host/self-check.d.ts +28 -0
  33. package/lib/host/self-check.js +111 -0
  34. package/lib/host/session-state.d.ts +19 -0
  35. package/lib/host/session-state.js +289 -0
  36. package/lib/host/state-store.d.ts +14 -0
  37. package/lib/host/state-store.js +109 -0
  38. package/lib/host/steer-once.d.ts +13 -0
  39. package/lib/host/steer-once.js +25 -0
  40. package/lib/host/task-completion.d.ts +30 -0
  41. package/lib/host/task-completion.js +166 -0
  42. package/lib/host/types.d.ts +365 -0
  43. package/lib/host/types.js +11 -0
  44. package/package.json +17 -10
  45. package/service/.gitkeep +0 -0
  46. package/service/backend.mjs +0 -127
  47. package/service/config-store.mjs +0 -311
  48. package/service/danger-gate.mjs +0 -182
  49. package/service/dsh.mjs +0 -288
  50. package/service/log-analyze.mjs +0 -257
  51. package/service/log-read.mjs +0 -27
  52. package/service/log-store.mjs +0 -82
  53. package/service/loop-guard.mjs +0 -51
  54. package/service/manager-api.mjs +0 -137
  55. package/service/narrowing.mjs +0 -226
  56. package/service/quality-trend.mjs +0 -29
  57. package/service/self-check.mjs +0 -131
  58. package/service/session-state.mjs +0 -260
  59. package/service/state-store.mjs +0 -100
  60. package/service/steer-once.mjs +0 -25
  61. package/service/task-completion.mjs +0 -163
@@ -0,0 +1,289 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { redactCredentials } from "./excerpt.js";
3
+ import { advanceRun, severityOf } from "./quality-trend.js";
4
+ const KINDS = ["gate", "check", "narrow", "complete"];
5
+ const SEVERITIES = ["normal", "warn", "severe"];
6
+ function sessionKey(value) {
7
+ if (typeof value !== "string" || !value.trim() || value.length > 512) throw new Error("Invalid session ID");
8
+ return value.trim();
9
+ }
10
+ function emptyCounters() {
11
+ return {
12
+ attempts: 0,
13
+ failures: 0,
14
+ gate: { attempts: 0, failures: 0, ask: 0, deny: 0, allow: 0, actual: { allow: 0, deny: 0, error: 0 } },
15
+ check: { attempts: 0, failures: 0, low: 0, run: 0, severity: "normal" },
16
+ narrow: { attempts: 0, failures: 0, applied: 0, dropped: 0 },
17
+ complete: { attempts: 0, failures: 0, unsatisfied: 0, satisfied: 0, insufficient: 0, steered: 0 },
18
+ log: []
19
+ };
20
+ }
21
+ function emptyState() {
22
+ return { enabled: true, ...emptyCounters() };
23
+ }
24
+ function safeTool(value) {
25
+ return typeof value === "string" && value.trim() ? value.trim().slice(0, 128) : void 0;
26
+ }
27
+ const toCount = (value) => Number.isSafeInteger(value) && value >= 0 ? value : 0;
28
+ const asRecord = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
29
+ function coerceState(raw) {
30
+ const state = emptyState();
31
+ const record = asRecord(raw);
32
+ if (!record) return state;
33
+ if (typeof record.enabled === "boolean") state.enabled = record.enabled;
34
+ state.attempts = toCount(record.attempts);
35
+ state.failures = toCount(record.failures);
36
+ const g = asRecord(record.gate);
37
+ if (g) {
38
+ state.gate.attempts = toCount(g.attempts);
39
+ state.gate.failures = toCount(g.failures);
40
+ state.gate.ask = toCount(g.ask);
41
+ state.gate.deny = toCount(g.deny);
42
+ state.gate.allow = toCount(g.allow);
43
+ const a = asRecord(g.actual);
44
+ if (a) {
45
+ state.gate.actual.allow = toCount(a.allow);
46
+ state.gate.actual.deny = toCount(a.deny);
47
+ state.gate.actual.error = toCount(a.error);
48
+ }
49
+ }
50
+ const c = asRecord(record.check);
51
+ if (c) {
52
+ state.check.attempts = toCount(c.attempts);
53
+ state.check.failures = toCount(c.failures);
54
+ state.check.low = toCount(c.low);
55
+ state.check.run = toCount(c.run);
56
+ state.check.severity = typeof c.severity === "string" && SEVERITIES.includes(c.severity) ? c.severity : "normal";
57
+ }
58
+ const n = asRecord(record.narrow);
59
+ if (n) {
60
+ state.narrow.attempts = toCount(n.attempts);
61
+ state.narrow.failures = toCount(n.failures);
62
+ state.narrow.applied = toCount(n.applied);
63
+ state.narrow.dropped = toCount(n.dropped);
64
+ }
65
+ const k = asRecord(record.complete);
66
+ if (k) {
67
+ state.complete.attempts = toCount(k.attempts);
68
+ state.complete.failures = toCount(k.failures);
69
+ state.complete.unsatisfied = toCount(k.unsatisfied);
70
+ state.complete.satisfied = toCount(k.satisfied);
71
+ state.complete.insufficient = toCount(k.insufficient);
72
+ state.complete.steered = toCount(k.steered);
73
+ }
74
+ if (Array.isArray(record.log)) {
75
+ state.log = record.log.filter((entry) => Boolean(asRecord(entry)) && typeof entry.kind === "string").map((entry) => ({ ...entry }));
76
+ }
77
+ return state;
78
+ }
79
+ function createSessionState({ maxSessions = 1024, maxLogEntries = 50, onLog, loadState, persistState } = {}) {
80
+ if (!Number.isSafeInteger(maxSessions) || maxSessions < 1) throw new Error("Invalid session limit");
81
+ if (!Number.isSafeInteger(maxLogEntries) || maxLogEntries < 1) throw new Error("Invalid log limit");
82
+ const sessions = /* @__PURE__ */ new Map();
83
+ const sink = typeof onLog === "function" ? onLog : void 0;
84
+ const load = typeof loadState === "function" ? loadState : void 0;
85
+ const persist = typeof persistState === "function" ? persistState : void 0;
86
+ const loaded = /* @__PURE__ */ new Map();
87
+ const bump = (key, state) => {
88
+ if (persist) {
89
+ try {
90
+ persist(key, state);
91
+ } catch {
92
+ }
93
+ }
94
+ };
95
+ const writable = (id) => {
96
+ const key = sessionKey(id);
97
+ let state = sessions.get(key);
98
+ if (!state) {
99
+ if (sessions.size >= maxSessions) sessions.delete(sessions.keys().next().value);
100
+ state = emptyState();
101
+ sessions.set(key, state);
102
+ }
103
+ return state;
104
+ };
105
+ return {
106
+ // Rehydrate a session's live counters/log from the durable store the first
107
+ // time it is touched this process, before any new event mutates it — so disk
108
+ // history and in-process events never double-count. Memoized per id; a no-op
109
+ // when no loadState was given or the session already has live state. Callers
110
+ // should await this before the first snapshot/record of a resumed session
111
+ // (the plugin does so at the turn's first step and in the panel routes).
112
+ async ensureLoaded(id) {
113
+ if (!load) return;
114
+ let key;
115
+ try {
116
+ key = sessionKey(id);
117
+ } catch {
118
+ return;
119
+ }
120
+ const existing = loaded.get(key);
121
+ if (existing) return existing;
122
+ const promise = (async () => {
123
+ if (sessions.has(key)) return;
124
+ let raw;
125
+ try {
126
+ raw = await load(key);
127
+ } catch {
128
+ raw = void 0;
129
+ }
130
+ if (!raw) return;
131
+ if (sessions.has(key)) return;
132
+ const state = coerceState(raw);
133
+ if (state.log.length > maxLogEntries) state.log.splice(0, state.log.length - maxLogEntries);
134
+ if (sessions.size >= maxSessions) sessions.delete(sessions.keys().next().value);
135
+ sessions.set(key, state);
136
+ })();
137
+ if (loaded.size >= maxSessions) loaded.delete(loaded.keys().next().value);
138
+ loaded.set(key, promise);
139
+ return promise;
140
+ },
141
+ setEnabled(id, enabled) {
142
+ if (typeof enabled !== "boolean") throw new Error("Invalid enabled value");
143
+ const state = writable(id);
144
+ state.enabled = enabled;
145
+ bump(sessionKey(id), state);
146
+ },
147
+ snapshot(id) {
148
+ const stored = sessions.get(sessionKey(id));
149
+ const state = stored ?? emptyState();
150
+ const value = { enabled: state.enabled, hasAutomaticDecisions: state.attempts > 0, attempts: state.attempts, failures: state.failures };
151
+ if (state.gate.attempts > 0) value.gate = { ...state.gate, actual: { ...state.gate.actual } };
152
+ if (state.check.attempts > 0) value.check = { attempts: state.check.attempts, failures: state.check.failures, low: state.check.low, trend: { run: state.check.run, severity: state.check.severity } };
153
+ if (state.narrow.attempts > 0) value.narrow = { ...state.narrow };
154
+ if (state.complete.attempts > 0) value.complete = { ...state.complete };
155
+ if (state.log.length > 0) value.log = state.log.map((entry) => ({ ...entry }));
156
+ return value;
157
+ },
158
+ record(id, kind, outcome) {
159
+ if (kind !== "gate" || !["allow", "ask", "deny", "error"].includes(outcome)) throw new Error("Invalid automatic decision");
160
+ const state = writable(id);
161
+ state.attempts++;
162
+ state.gate.attempts++;
163
+ if (outcome === "error") {
164
+ state.failures++;
165
+ state.gate.failures++;
166
+ } else if (outcome === "allow") state.gate.allow++;
167
+ else if (outcome === "ask") state.gate.ask++;
168
+ else state.gate.deny++;
169
+ bump(sessionKey(id), state);
170
+ },
171
+ recordActual(id, outcome) {
172
+ if (!["allow", "deny", "error"].includes(outcome)) throw new Error("Invalid actual decision");
173
+ const state = writable(id);
174
+ const actual = state.gate.actual;
175
+ if (outcome === "allow") actual.allow++;
176
+ else if (outcome === "deny") actual.deny++;
177
+ else actual.error++;
178
+ bump(sessionKey(id), state);
179
+ },
180
+ recordCheck(id, outcome, trend) {
181
+ if (!["ok", "low", "error"].includes(outcome)) throw new Error("Invalid self-check outcome");
182
+ const state = writable(id);
183
+ const check = state.check;
184
+ state.attempts++;
185
+ check.attempts++;
186
+ if (outcome === "error") {
187
+ state.failures++;
188
+ check.failures++;
189
+ } else {
190
+ if (outcome === "low") check.low++;
191
+ if (trend && typeof trend === "object") {
192
+ check.run = advanceRun(check.run, { lowScore: outcome === "low", confident: trend.confident === true });
193
+ check.severity = severityOf(check.run, {
194
+ trendRun: typeof trend.trendRun === "number" ? trend.trendRun : void 0,
195
+ trendSevereRun: typeof trend.trendSevereRun === "number" ? trend.trendSevereRun : void 0
196
+ });
197
+ }
198
+ }
199
+ bump(sessionKey(id), state);
200
+ },
201
+ recordNarrow(id, outcome, dropped = 0, applied = outcome === "applied") {
202
+ if (!["ok", "applied", "error"].includes(outcome)) throw new Error("Invalid narrowing outcome");
203
+ const state = writable(id);
204
+ const narrow = state.narrow;
205
+ state.attempts++;
206
+ narrow.attempts++;
207
+ if (outcome === "error") {
208
+ state.failures++;
209
+ narrow.failures++;
210
+ } else if (outcome === "applied") {
211
+ if (applied) narrow.applied++;
212
+ if (Number.isSafeInteger(dropped) && dropped > 0) narrow.dropped += dropped;
213
+ }
214
+ bump(sessionKey(id), state);
215
+ },
216
+ // Task-completion checking. `ok`/`unsatisfied` are real evaluations; `error`
217
+ // is an unevaluated failure counted as a failure, never as "unsatisfied".
218
+ // The per-condition three-state tallies come from `detail`.
219
+ recordComplete(id, outcome, detail = {}) {
220
+ if (!["ok", "unsatisfied", "error"].includes(outcome)) throw new Error("Invalid task-completion outcome");
221
+ const state = writable(id);
222
+ const complete = state.complete;
223
+ state.attempts++;
224
+ complete.attempts++;
225
+ if (outcome === "error") {
226
+ state.failures++;
227
+ complete.failures++;
228
+ } else {
229
+ if (Number.isSafeInteger(detail.satisfied) && detail.satisfied > 0) complete.satisfied += detail.satisfied;
230
+ if (Number.isSafeInteger(detail.unsatisfied) && detail.unsatisfied > 0) complete.unsatisfied += detail.unsatisfied;
231
+ if (Number.isSafeInteger(detail.insufficient) && detail.insufficient > 0) complete.insufficient += detail.insufficient;
232
+ if (detail.steered === true) complete.steered++;
233
+ }
234
+ bump(sessionKey(id), state);
235
+ },
236
+ log(id, entry) {
237
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) throw new Error("Invalid log entry");
238
+ const kind = entry.kind;
239
+ if (typeof kind !== "string" || !KINDS.includes(kind)) throw new Error("Invalid log entry");
240
+ const record = { id: randomUUID(), at: Date.now(), kind, outcome: typeof entry.outcome === "string" ? entry.outcome.slice(0, 32) : "" };
241
+ const tool = safeTool(entry.tool);
242
+ if (tool) record.tool = tool;
243
+ if (typeof entry.suggestion === "string" && entry.suggestion) record.suggestion = entry.suggestion.slice(0, 16);
244
+ if (typeof entry.action === "string" && entry.action) record.action = entry.action.slice(0, 16);
245
+ if (typeof entry.reason === "string" && entry.reason) record.reason = entry.reason.slice(0, 32);
246
+ if (typeof entry.subject === "string" && entry.subject.trim()) record.subject = redactCredentials(entry.subject).replace(/\s+/g, " ").trim().slice(0, 120);
247
+ if (typeof entry.command === "string" && entry.command.trim()) record.command = entry.command.replace(/\s+/g, " ").trim().slice(0, 512);
248
+ if (typeof entry.path === "string" && entry.path.trim()) record.path = entry.path.replace(/\s+/g, " ").trim().slice(0, 512);
249
+ if (typeof entry.task === "string" && entry.task.trim()) record.task = redactCredentials(entry.task).replace(/\s+/g, " ").trim().slice(0, 512);
250
+ if (Number.isSafeInteger(entry.turn) && entry.turn >= 0) record.turn = entry.turn;
251
+ if (Number.isSafeInteger(entry.step) && entry.step >= 0) record.step = entry.step;
252
+ if (typeof entry.mode === "string" && entry.mode) record.mode = entry.mode.slice(0, 16);
253
+ if (typeof entry.score === "number" && Number.isFinite(entry.score)) record.score = Math.round(entry.score * 100) / 100;
254
+ if (typeof entry.confidence === "number" && Number.isFinite(entry.confidence)) record.confidence = Math.round(entry.confidence * 100) / 100;
255
+ if (Number.isSafeInteger(entry.dropped) && entry.dropped >= 0) record.dropped = entry.dropped;
256
+ if (Number.isSafeInteger(entry.kept) && entry.kept >= 0) record.kept = entry.kept;
257
+ if (typeof entry.applied === "boolean") record.applied = entry.applied;
258
+ if (Number.isSafeInteger(entry.candidates) && entry.candidates >= 0) record.candidates = entry.candidates;
259
+ if (Number.isSafeInteger(entry.conditions) && entry.conditions >= 0) record.conditions = entry.conditions;
260
+ if (Number.isSafeInteger(entry.satisfied) && entry.satisfied >= 0) record.satisfied = entry.satisfied;
261
+ if (Number.isSafeInteger(entry.unsatisfied) && entry.unsatisfied >= 0) record.unsatisfied = entry.unsatisfied;
262
+ if (Number.isSafeInteger(entry.insufficient) && entry.insufficient >= 0) record.insufficient = entry.insufficient;
263
+ if (entry.steered === true) record.steered = true;
264
+ if (entry.lowConfidence === true) record.lowConfidence = true;
265
+ if (Array.isArray(entry.tools)) {
266
+ const names = entry.tools.filter((name) => typeof name === "string" && Boolean(name.trim())).slice(0, 12).map((name) => name.trim().slice(0, 64));
267
+ if (names.length > 0) record.tools = names;
268
+ }
269
+ const state = writable(id);
270
+ state.log.push(record);
271
+ if (state.log.length > maxLogEntries) state.log.splice(0, state.log.length - maxLogEntries);
272
+ if (sink) {
273
+ const full = { ...record, sessionId: sessionKey(id) };
274
+ if (Array.isArray(entry.tools)) {
275
+ const allNames = entry.tools.filter((name) => typeof name === "string" && Boolean(name.trim())).map((name) => name.trim());
276
+ if (allNames.length > 0) full.tools = allNames;
277
+ }
278
+ try {
279
+ sink(full);
280
+ } catch {
281
+ }
282
+ }
283
+ bump(sessionKey(id), state);
284
+ }
285
+ };
286
+ }
287
+ export {
288
+ createSessionState
289
+ };
@@ -0,0 +1,14 @@
1
+ export declare function stateDir(): string;
2
+ export declare function safeStateId(id: unknown): string | undefined;
3
+ export type StateStoreOptions = {
4
+ dir?: string;
5
+ flushIntervalMs?: number;
6
+ retentionDays?: number;
7
+ now?: () => number;
8
+ };
9
+ export declare function createStateStore({ dir, flushIntervalMs, retentionDays, now }?: StateStoreOptions): {
10
+ load(id: unknown): Promise<Record<string, unknown> | undefined>;
11
+ save(id: unknown, state: unknown): void;
12
+ flush: () => Promise<void>;
13
+ dispose(): Promise<void>;
14
+ };
@@ -0,0 +1,109 @@
1
+ import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { isObject } from "./types.js";
6
+ function stateDir() {
7
+ return join(homedir(), ".config", "dsh-decision-layer", "state");
8
+ }
9
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
10
+ function safeStateId(id) {
11
+ return typeof id === "string" && SAFE_ID.test(id) && !id.includes("..") ? id : void 0;
12
+ }
13
+ function createStateStore({
14
+ dir = stateDir(),
15
+ flushIntervalMs = 2e3,
16
+ retentionDays = 30,
17
+ now = () => Date.now()
18
+ } = {}) {
19
+ const pending = /* @__PURE__ */ new Map();
20
+ let timer;
21
+ let flushing = false;
22
+ let lastPrune = 0;
23
+ const fileFor = (id) => join(dir, `${id}.json`);
24
+ const writeOne = async (id, state) => {
25
+ const temporary = join(dir, `${id}.${randomUUID()}.tmp`);
26
+ try {
27
+ await writeFile(temporary, `${JSON.stringify({ v: 1, at: now(), state })}
28
+ `, { mode: 384, flag: "wx" });
29
+ await rename(temporary, fileFor(id));
30
+ } finally {
31
+ await rm(temporary, { force: true }).catch(() => {
32
+ });
33
+ }
34
+ };
35
+ const prune = async () => {
36
+ let names;
37
+ try {
38
+ names = await readdir(dir);
39
+ } catch {
40
+ return;
41
+ }
42
+ const cutoff = now() - retentionDays * 24 * 60 * 60 * 1e3;
43
+ await Promise.all(names.map(async (name) => {
44
+ if (!name.endsWith(".json")) return;
45
+ try {
46
+ const info = await stat(join(dir, name));
47
+ if (info.mtimeMs < cutoff) await rm(join(dir, name), { force: true }).catch(() => {
48
+ });
49
+ } catch {
50
+ }
51
+ }));
52
+ };
53
+ const flush = async () => {
54
+ if (flushing || pending.size === 0) return;
55
+ flushing = true;
56
+ const batch = [...pending.entries()];
57
+ pending.clear();
58
+ try {
59
+ await mkdir(dir, { recursive: true, mode: 448 });
60
+ for (const [id, state] of batch) await writeOne(id, state).catch(() => {
61
+ });
62
+ if (now() - lastPrune > 60 * 60 * 1e3) {
63
+ lastPrune = now();
64
+ await prune();
65
+ }
66
+ } catch {
67
+ } finally {
68
+ flushing = false;
69
+ }
70
+ };
71
+ return {
72
+ // Read a session's persisted state, or undefined when there is no usable file.
73
+ async load(id) {
74
+ const safe = safeStateId(id);
75
+ if (!safe) return void 0;
76
+ try {
77
+ const parsed = JSON.parse(await readFile(fileFor(safe), "utf8"));
78
+ if (isObject(parsed) && isObject(parsed.state)) {
79
+ return parsed.state;
80
+ }
81
+ } catch {
82
+ }
83
+ return void 0;
84
+ },
85
+ // Register the latest live state for a session and schedule a debounced flush.
86
+ save(id, state) {
87
+ const safe = safeStateId(id);
88
+ if (!safe || state === null || typeof state !== "object" || Array.isArray(state)) return;
89
+ pending.set(safe, state);
90
+ if (!timer) timer = setTimeout(() => {
91
+ timer = void 0;
92
+ void flush();
93
+ }, flushIntervalMs);
94
+ },
95
+ flush,
96
+ dispose() {
97
+ if (timer) {
98
+ clearTimeout(timer);
99
+ timer = void 0;
100
+ }
101
+ return flush();
102
+ }
103
+ };
104
+ }
105
+ export {
106
+ createStateStore,
107
+ safeStateId,
108
+ stateDir
109
+ };
@@ -0,0 +1,13 @@
1
+ export type SteerTurn = {
2
+ agent?: {
3
+ session?: {
4
+ id?: string;
5
+ };
6
+ };
7
+ turn?: number;
8
+ };
9
+ export declare function createSteerOnce({ maxTurns }?: {
10
+ maxTurns?: number;
11
+ }): {
12
+ attempt(turn: SteerTurn | undefined, run: () => void): boolean;
13
+ };
@@ -0,0 +1,25 @@
1
+ function createSteerOnce({ maxTurns = 4096 } = {}) {
2
+ const steeredTurns = /* @__PURE__ */ new Set();
3
+ const keyOf = (turn) => {
4
+ const id = turn?.agent?.session?.id;
5
+ const index = turn?.turn;
6
+ return typeof id === "string" && Number.isInteger(index) ? `${id}:${index}` : void 0;
7
+ };
8
+ return {
9
+ attempt(turn, run) {
10
+ const key = keyOf(turn);
11
+ if (key === void 0 || steeredTurns.has(key)) return false;
12
+ try {
13
+ run();
14
+ } catch {
15
+ return false;
16
+ }
17
+ steeredTurns.add(key);
18
+ if (steeredTurns.size > maxTurns) steeredTurns.delete(steeredTurns.values().next().value);
19
+ return true;
20
+ }
21
+ };
22
+ }
23
+ export {
24
+ createSteerOnce
25
+ };
@@ -0,0 +1,30 @@
1
+ import type { AgentLike, CompleteSettings, DecisionContext, EvaluateInput, Evaluator, SessionsFace } from './types.js';
2
+ export declare function extractConditions(userText: unknown): string[];
3
+ export declare function completionQuestions(conditions: unknown, evidence: unknown, answer: unknown, context?: DecisionContext): EvaluateInput;
4
+ export declare const DEFAULT_MIN_CONFIDENCE = 0.4;
5
+ export type CompletionTurn = {
6
+ agent?: AgentLike;
7
+ turn?: number;
8
+ signal?: AbortSignal;
9
+ output?: unknown;
10
+ userText?: unknown;
11
+ evidence?: unknown;
12
+ context?: DecisionContext;
13
+ };
14
+ export type CompletionResult = {
15
+ evaluated: boolean;
16
+ steered: boolean;
17
+ satisfied?: number;
18
+ unsatisfied?: number;
19
+ insufficient?: number;
20
+ };
21
+ export type TaskCompletionOptions = {
22
+ evaluate?: Evaluator;
23
+ sessions?: SessionsFace;
24
+ steer?: (agent: AgentLike, text: string) => unknown;
25
+ settings?: CompleteSettings | (() => Promise<CompleteSettings | undefined>);
26
+ minConfidence?: number;
27
+ };
28
+ export declare function createTaskCompletion({ evaluate, sessions, steer, settings, minConfidence }?: TaskCompletionOptions): {
29
+ review(turn: CompletionTurn | undefined): Promise<CompletionResult>;
30
+ };
@@ -0,0 +1,166 @@
1
+ import { taskExcerpt } from "./excerpt.js";
2
+ import { createSteerOnce } from "./steer-once.js";
3
+ import { errorReason, isObject, isProbability } from "./types.js";
4
+ const MAX_STATE = 8 * 1024;
5
+ const MAX_CONDITION = 512;
6
+ const MAX_CONDITIONS = 20;
7
+ const MAX_EVIDENCE = 40;
8
+ const STEER_TEXT = "\u672C\u56DE\u5408\u4EFB\u52A1\u5B8C\u6210\u6838\u5BF9\u53D1\u73B0\u6709\u660E\u786E\u4EA4\u4ED8\u6761\u4EF6\u5C1A\u672A\u6EE1\u8DB3\uFF1A\u8BF7\u5BF9\u7167\u7528\u6237\u5217\u51FA\u7684\u6761\u4EF6\u9010\u9879\u590D\u6838\uFF0C\u8865\u5B8C\u672A\u5B8C\u6210\u7684\u90E8\u5206\u3002";
9
+ function extractConditions(userText) {
10
+ const text = typeof userText === "string" ? userText : "";
11
+ if (!text.trim()) return [];
12
+ const conditions = [];
13
+ const push = (value) => {
14
+ const item = value.trim().replace(/\s+/g, " ").slice(0, MAX_CONDITION);
15
+ if (item && !conditions.includes(item)) conditions.push(item);
16
+ };
17
+ const lines = text.split(/\r?\n/);
18
+ let matchedStructured = false;
19
+ for (const line of lines) {
20
+ const numbered = line.match(/^\s*(?:[((]?\d+[)).、]|\d+\s*[.)、])\s+(.*)$/)?.[1];
21
+ const bullet = line.match(/^\s*[-*•]\s+(.*)$/)?.[1];
22
+ if (numbered?.trim()) {
23
+ push(numbered);
24
+ matchedStructured = true;
25
+ } else if (bullet?.trim()) {
26
+ push(bullet);
27
+ matchedStructured = true;
28
+ }
29
+ }
30
+ if (!matchedStructured) {
31
+ const compact = text.trim();
32
+ if (!/[\r\n]/.test(compact)) {
33
+ const parts = compact.split(/[、,,]/).map((part) => part.trim()).filter(Boolean);
34
+ const floor = compact.includes("\u3001") ? 2 : 3;
35
+ if (parts.length >= floor) for (const part of parts) push(part);
36
+ }
37
+ }
38
+ return conditions.slice(0, MAX_CONDITIONS);
39
+ }
40
+ function completionQuestions(conditions, evidence, answer, context) {
41
+ const items = Array.isArray(conditions) ? conditions.slice(0, MAX_CONDITIONS) : [];
42
+ const tools = Array.isArray(evidence) ? evidence.filter((item) => isObject(item) && typeof item.tool === "string").slice(0, MAX_EVIDENCE).map((item) => {
43
+ const source = item;
44
+ const entry = {
45
+ tool: source.tool.slice(0, 128),
46
+ outcome: source.outcome === "error" ? "error" : "ok"
47
+ };
48
+ for (const key of ["command", "path", "result"]) {
49
+ const value = source[key];
50
+ if (typeof value === "string" && value.trim()) entry[key] = value.slice(0, 256);
51
+ }
52
+ return entry;
53
+ }) : [];
54
+ const state = {
55
+ ...context?.request ? { request: context.request } : {},
56
+ ...context?.background?.length ? { background: context.background } : {},
57
+ conditions: items.map((text, index) => ({ id: `c${index}`, text })),
58
+ toolEvidence: tools,
59
+ finalReply: String(answer ?? "").slice(0, MAX_STATE),
60
+ note: "\u5DE5\u5177\u8BC1\u636E\uFF08toolEvidence\uFF09\u662F\u5224\u5B9A\u4F9D\u636E\uFF1BfinalReply \u81EA\u79F0\u5B8C\u6210\u4E0D\u4F5C\u4E3A\u8BC1\u636E\uFF0Cbackground \u53EA\u662F\u80CC\u666F\u800C\u4E0D\u662F\u672C\u8F6E\u8981\u6C42\u3002\u7F3A\u5C11\u8BC1\u636E\u65F6\u9009 insufficient_evidence\u3002"
61
+ };
62
+ const questions = Object.fromEntries(items.map((text, index) => [`c${index}`, {
63
+ type: "choice",
64
+ instructions: {
65
+ condition: text,
66
+ question: "\u6839\u636E\u5F53\u524D\u5DE5\u5177\u8BC1\u636E\u4E0E\u6700\u7EC8\u56DE\u590D\uFF0C\u8FD9\u4E2A\u4EA4\u4ED8\u6761\u4EF6\u7684\u5B8C\u6210\u60C5\u51B5\u5C5E\u4E8E\u54EA\u4E00\u79CD\uFF1F"
67
+ },
68
+ criteria: {
69
+ satisfied: "\u6709\u5DE5\u5177\u8BC1\u636E\u6216\u7528\u6237\u660E\u786E\u786E\u8BA4\u8868\u660E\u8BE5\u6761\u4EF6\u5DF2\u5B8C\u6210",
70
+ unsatisfied: "\u6709\u8BC1\u636E\u8868\u660E\u8BE5\u6761\u4EF6\u672A\u5B8C\u6210\u6216\u505A\u9519",
71
+ insufficient_evidence: "\u73B0\u6709\u8BC1\u636E\u4E0D\u8DB3\u4EE5\u5224\u65AD\u8BE5\u6761\u4EF6\u662F\u5426\u5B8C\u6210"
72
+ }
73
+ }]));
74
+ return { state, questions };
75
+ }
76
+ const DEFAULT_MIN_CONFIDENCE = 0.4;
77
+ function createTaskCompletion({ evaluate, sessions, steer, settings, minConfidence = DEFAULT_MIN_CONFIDENCE } = {}) {
78
+ if (typeof evaluate !== "function") throw new Error("Task completion requires an evaluator");
79
+ const steerOnce = createSteerOnce();
80
+ const resolveSettings = async () => {
81
+ let raw = {};
82
+ try {
83
+ raw = (typeof settings === "function" ? await settings() : settings) ?? {};
84
+ } catch {
85
+ raw = {};
86
+ }
87
+ const mode = raw?.mode === "steer" ? "steer" : "observe";
88
+ const minConditions = Number.isSafeInteger(raw?.minConditions) && raw.minConditions >= 2 ? raw.minConditions : 2;
89
+ const confidenceFloor = isProbability(raw?.minConfidence) ? raw.minConfidence : minConfidence;
90
+ return { mode, minConditions, minConfidence: confidenceFloor };
91
+ };
92
+ const record = (turn, outcome, detail = {}) => {
93
+ const id = turn?.agent?.session?.id;
94
+ if (!sessions || typeof id !== "string" || typeof sessions.recordComplete !== "function") return;
95
+ try {
96
+ sessions.recordComplete(id, outcome, detail);
97
+ } catch {
98
+ }
99
+ if (typeof sessions.log !== "function") return;
100
+ try {
101
+ sessions.log(id, { kind: "complete", outcome, ...detail });
102
+ } catch {
103
+ }
104
+ };
105
+ const skip = { evaluated: false, steered: false };
106
+ return {
107
+ async review(turn) {
108
+ const output = typeof turn?.output === "string" ? turn.output.trim() : "";
109
+ const task = taskExcerpt(turn?.context?.request ?? turn?.userText);
110
+ const taskDetail = task ? { task } : {};
111
+ if (Number.isSafeInteger(turn?.turn) && turn?.turn >= 0) taskDetail.turn = turn?.turn;
112
+ const { mode, minConditions, minConfidence: confidenceFloor } = await resolveSettings();
113
+ const conditions = extractConditions(turn?.userText);
114
+ if (conditions.length < minConditions) return { ...skip };
115
+ const evidence = Array.isArray(turn?.evidence) ? turn.evidence : [];
116
+ if (evidence.length === 0) return { ...skip };
117
+ const id = turn?.agent?.session?.id;
118
+ if (sessions && typeof id === "string") {
119
+ try {
120
+ if (sessions.snapshot(id).enabled === false) return { ...skip };
121
+ } catch {
122
+ return { ...skip };
123
+ }
124
+ }
125
+ let answers;
126
+ try {
127
+ const result = await evaluate(completionQuestions(conditions, evidence, output, turn?.context), { signal: turn?.signal });
128
+ answers = isObject(result?.answers) ? result.answers : void 0;
129
+ } catch (error) {
130
+ record(turn, "error", { ...taskDetail, reason: errorReason(error) });
131
+ return { ...skip };
132
+ }
133
+ if (!answers) {
134
+ record(turn, "error", { ...taskDetail, reason: "invalid-response" });
135
+ return { ...skip };
136
+ }
137
+ let satisfied = 0, unsatisfied = 0, insufficient = 0;
138
+ let unsatisfiedMinConf = 1;
139
+ for (let index = 0; index < conditions.length; index++) {
140
+ const answer = answers[`c${index}`];
141
+ if (!isObject(answer) || answer.type !== "choice" || typeof answer.choice !== "string") {
142
+ insufficient++;
143
+ continue;
144
+ }
145
+ if (answer.choice === "satisfied") satisfied++;
146
+ else if (answer.choice === "unsatisfied") {
147
+ unsatisfied++;
148
+ if (isProbability(answer.confidence)) unsatisfiedMinConf = Math.min(unsatisfiedMinConf, answer.confidence);
149
+ } else insufficient++;
150
+ }
151
+ const outcome = unsatisfied > 0 ? "unsatisfied" : "ok";
152
+ const detail = { ...taskDetail, conditions: conditions.length, satisfied, unsatisfied, insufficient };
153
+ const confident = unsatisfied > 0 && unsatisfiedMinConf >= confidenceFloor;
154
+ const steered = mode === "steer" && confident && typeof steer === "function" && turn?.agent ? steerOnce.attempt(turn, () => steer(turn.agent, STEER_TEXT)) : false;
155
+ if (steered) detail.steered = true;
156
+ record(turn, outcome, detail);
157
+ return { evaluated: true, steered, satisfied, unsatisfied, insufficient };
158
+ }
159
+ };
160
+ }
161
+ export {
162
+ DEFAULT_MIN_CONFIDENCE,
163
+ completionQuestions,
164
+ createTaskCompletion,
165
+ extractConditions
166
+ };