dsh-plugin-rollout-scout 1.3.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/LICENSE +21 -0
- package/README.en.md +134 -0
- package/README.md +132 -0
- package/_wrap-client.mjs +32 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +1319 -0
- package/lib/fixtures.js +145 -0
- package/lib/index.js +1583 -0
- package/package.json +54 -0
- package/plugin.client.js +1295 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1583 @@
|
|
|
1
|
+
// dsh-plugin-rollout-scout — host half.
|
|
2
|
+
//
|
|
3
|
+
// Fishes for a limited-rollout conversation model by starting short probe
|
|
4
|
+
// conversations and reading their chain-of-thought live off the session/event
|
|
5
|
+
// firehose. A paragraph opening with "Let me" marks the old model. The
|
|
6
|
+
// rollout path summarises CoT with a small model: even paragraphs, bursty
|
|
7
|
+
// pauses, I'll/I'm openings — a leading "We need" is only a score penalty.
|
|
8
|
+
// The /rollout-scout route drives it: GET returns live state for the console,
|
|
9
|
+
// POST starts a run, pauses or resumes launching, force-stops everything in
|
|
10
|
+
// flight, or clears finished probes.
|
|
11
|
+
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
import { FIXTURES } from './fixtures.js';
|
|
14
|
+
import fs from 'node:fs/promises';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
const ROLLOUT_SCOUT_PATH = '/rollout-scout';
|
|
19
|
+
|
|
20
|
+
// DSH Desktop registers this namespace from its own notification plugin, in
|
|
21
|
+
// this same host context. Reading it tells the console whether a run is about
|
|
22
|
+
// to raise one system toast per finished probe; writing it is how the "turn
|
|
23
|
+
// them off" button in the pre-flight dialog works. A web-only harness never
|
|
24
|
+
// registers it, and then there is nothing to warn about.
|
|
25
|
+
const DESKTOP_NOTIFICATIONS_NS = 'dsh-desktop-notifications';
|
|
26
|
+
|
|
27
|
+
// Marks the probe prompt as ours rather than as something the user typed.
|
|
28
|
+
const PLUGIN_SOURCE = 'dsh-plugin-rollout-scout';
|
|
29
|
+
|
|
30
|
+
const name = 'rollout-scout';
|
|
31
|
+
const inject = [
|
|
32
|
+
'agents',
|
|
33
|
+
'sessions',
|
|
34
|
+
'sessionPersistence',
|
|
35
|
+
'workspaceRegistry',
|
|
36
|
+
'webServer',
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/* -------------------------------------------------------------- classifier -- */
|
|
40
|
+
|
|
41
|
+
// The tell is how a paragraph OPENS, not how often a phrase occurs across the
|
|
42
|
+
// whole text: a running tally drifts negative with length alone, so a long but
|
|
43
|
+
// genuinely promising chain-of-thought eventually accumulates enough "Let me"
|
|
44
|
+
// to be killed even while opening paragraph after paragraph the new way.
|
|
45
|
+
//
|
|
46
|
+
// The two decisive signals are not symmetric. "Let me" opening any paragraph
|
|
47
|
+
// settles the probe as the old model. "I'll" only proves the rollout model
|
|
48
|
+
// when it opens the WHOLE chain-of-thought: old-model reasoning happily opens
|
|
49
|
+
// a middle paragraph with "I'll create a single HTML file..." and then says
|
|
50
|
+
// "Let me build..." further down, so treating any "I'll" as proof produced
|
|
51
|
+
// false positives. Elsewhere it is one positive signal among several.
|
|
52
|
+
// Only the opening stretch of a paragraph is inspected — never its body.
|
|
53
|
+
const OPENING_CHARS = 48;
|
|
54
|
+
// The phrase rarely sits at character zero: the old model writes "The
|
|
55
|
+
// directory is empty. Let me create a 3D scene." and the new one "To avoid
|
|
56
|
+
// conflicts, I'll keep I18n.cs edits separate." Anchoring at the very start
|
|
57
|
+
// misses both, so the whole opening window is searched instead.
|
|
58
|
+
const DECISIVE_OLD = /\bLet me\b/i;
|
|
59
|
+
// Only meaningful on the first paragraph of the whole chain-of-thought.
|
|
60
|
+
const DECISIVE_NEW_FIRST = /^I'll\b/i;
|
|
61
|
+
// First-person planning voice. "I" is always capitalised, so these stay
|
|
62
|
+
// case-sensitive and cannot match inside another word.
|
|
63
|
+
const POSITIVE_OPENING = /\b(?:I'll|I will|I'm|I am|I've|I have|I need|I think|I also)\b/;
|
|
64
|
+
// "For" only counts when it actually opens the paragraph: lowercase "for"
|
|
65
|
+
// is far too common mid-sentence to mean anything.
|
|
66
|
+
const POSITIVE_FOR = /^For\b/;
|
|
67
|
+
// "We need" / "we will" in an opening count against the score, but they are
|
|
68
|
+
// not a kill: the rollout path often runs a small model that summarises the
|
|
69
|
+
// chain-of-thought, and that summariser commonly starts "We need to build…".
|
|
70
|
+
const NEGATIVE_OPENING = /\b(?:let me|let us|let's|we need|we will|we should|we can|we'll|we're|we've|we)\b/i;
|
|
71
|
+
// Summariser CoT arrives as even, essay-sized paragraphs. Old-model dumps
|
|
72
|
+
// are one blob or a mix of tiny "Let me" lines and a long irregular dump.
|
|
73
|
+
const SHAPE_MIN_PARAS = 3;
|
|
74
|
+
const SHAPE_MIN_CHARS = 80;
|
|
75
|
+
const SHAPE_MAX_CV = 0.85;
|
|
76
|
+
// Output-pause-output: the summariser writes a burst, stalls, then another
|
|
77
|
+
// burst. Gaps shorter than this are treated as ordinary streaming jitter.
|
|
78
|
+
const PAUSE_MS = 1400;
|
|
79
|
+
const BURST_MIN_CHARS = 80;
|
|
80
|
+
|
|
81
|
+
// A chain-of-thought that is actually *thinking* in Chinese is its own
|
|
82
|
+
// verdict, independent of the openings. Measured as a share of the letters
|
|
83
|
+
// rather than a raw count, so quoting a Chinese prompt inside otherwise
|
|
84
|
+
// English reasoning cannot trigger it. Punctuation, digits and whitespace
|
|
85
|
+
// are language-neutral and excluded from both sides.
|
|
86
|
+
const CJK = /[㐀-鿿豈-]/g;
|
|
87
|
+
const LATIN = /[A-Za-z]/g;
|
|
88
|
+
// Below this many letters the share is too noisy to act on.
|
|
89
|
+
const LANGUAGE_MIN_CHARS = 24;
|
|
90
|
+
|
|
91
|
+
function chineseShare(text) {
|
|
92
|
+
const cjk = (text.match(CJK) ?? []).length;
|
|
93
|
+
const latin = (text.match(LATIN) ?? []).length;
|
|
94
|
+
const total = cjk + latin;
|
|
95
|
+
if (total < LANGUAGE_MIN_CHARS) return 0;
|
|
96
|
+
return cjk / total;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Phrase chips need the sign from here: matching against lowercase
|
|
100
|
+
* literals on the client painted "We need" green even when it scored
|
|
101
|
+
* as the old model. */
|
|
102
|
+
function addHit(hits, phrase, sign) {
|
|
103
|
+
const existing = hits[phrase];
|
|
104
|
+
if (existing) existing.count += 1;
|
|
105
|
+
else hits[phrase] = { count: 1, sign };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Split a chain-of-thought into paragraphs whose openings are already
|
|
110
|
+
* fixed. A paragraph opening stops changing once OPENING_CHARS have
|
|
111
|
+
* arrived — waiting for a newline left single-blob chains-of-thought
|
|
112
|
+
* (the common case) stuck at 50% with "no classified opening" until
|
|
113
|
+
* the turn ended, which is why "We need respond in Chinese…" probes
|
|
114
|
+
* sat under the keep mark without being discarded.
|
|
115
|
+
*/
|
|
116
|
+
function paragraphShape(paragraphs) {
|
|
117
|
+
if (paragraphs.length < SHAPE_MIN_PARAS) return false;
|
|
118
|
+
const lengths = paragraphs.map((p) => p.length);
|
|
119
|
+
const long = lengths.filter((n) => n >= SHAPE_MIN_CHARS).length;
|
|
120
|
+
if (long < SHAPE_MIN_PARAS) return false;
|
|
121
|
+
const mean = lengths.reduce((a, b) => a + b, 0) / lengths.length;
|
|
122
|
+
if (mean < SHAPE_MIN_CHARS) return false;
|
|
123
|
+
let variance = 0;
|
|
124
|
+
for (const n of lengths) variance += (n - mean) ** 2;
|
|
125
|
+
const cv = Math.sqrt(variance / lengths.length) / mean;
|
|
126
|
+
return cv <= SHAPE_MAX_CV;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function settledParagraphs(text, final) {
|
|
130
|
+
const parts = text.split(/\n+/).map((p) => p.trim()).filter((p) => p !== '');
|
|
131
|
+
if (final || parts.length === 0) return parts;
|
|
132
|
+
const last = parts[parts.length - 1];
|
|
133
|
+
if (last.length >= OPENING_CHARS) return parts;
|
|
134
|
+
return parts.slice(0, -1);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Classify by paragraph openings. `positive` counts paragraphs that open the
|
|
139
|
+
* way the rollout model does; `paragraphs` is how many complete openings have
|
|
140
|
+
* been seen, which is what the discard window measures against.
|
|
141
|
+
*/
|
|
142
|
+
function classify(text, final) {
|
|
143
|
+
const paragraphs = settledParagraphs(text, final);
|
|
144
|
+
const hits = {};
|
|
145
|
+
let positive = 0;
|
|
146
|
+
let negative = 0;
|
|
147
|
+
let decisive = null;
|
|
148
|
+
for (let index = 0; index < paragraphs.length; index += 1) {
|
|
149
|
+
const paragraph = paragraphs[index];
|
|
150
|
+
const opening = paragraph.slice(0, OPENING_CHARS);
|
|
151
|
+
// Only the very first paragraph can prove the rollout model, and a
|
|
152
|
+
// later "Let me" opening always overrides it: old-model reasoning
|
|
153
|
+
// often starts with "I'll create a single HTML file…" then says
|
|
154
|
+
// "Let me build…" further down. Locking the first I'll left those
|
|
155
|
+
// probes green forever while the meter dropped to 0%. The override
|
|
156
|
+
// works because DECISIVE_OLD is tested after this on every paragraph.
|
|
157
|
+
if (index === 0 && DECISIVE_NEW_FIRST.test(opening)) decisive = 'new';
|
|
158
|
+
if (DECISIVE_OLD.test(opening)) decisive = 'old';
|
|
159
|
+
const positiveMatch = opening.match(POSITIVE_OPENING) ?? opening.match(POSITIVE_FOR);
|
|
160
|
+
const negativeMatch = opening.match(NEGATIVE_OPENING);
|
|
161
|
+
// "We need" in the first paragraph is a negative opening, not a
|
|
162
|
+
// kill: the summariser often starts "We need to build…" and then
|
|
163
|
+
// writes I'll / I'm for the rest. Only "Let me" as a paragraph
|
|
164
|
+
// opening is decisive against.
|
|
165
|
+
if (positiveMatch) {
|
|
166
|
+
positive += 1;
|
|
167
|
+
addHit(hits, positiveMatch[0], 'pos');
|
|
168
|
+
} else if (negativeMatch) {
|
|
169
|
+
negative += 1;
|
|
170
|
+
addHit(hits, negativeMatch[0], 'neg');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Shown as confidence: the share of classified openings reading as the
|
|
174
|
+
// rollout model, held near 0.5 until openings actually accumulate. A
|
|
175
|
+
// decisive "Let me" opening pins it to zero. Even paragraph shape is
|
|
176
|
+
// one extra positive — the summariser writes regular blocks.
|
|
177
|
+
const regular = paragraphShape(paragraphs);
|
|
178
|
+
const classified = positive + negative;
|
|
179
|
+
const extra = regular ? 1 : 0;
|
|
180
|
+
const score = decisive === 'new' ? 1
|
|
181
|
+
: decisive === 'old' ? 0
|
|
182
|
+
: (positive + extra + 1) / (classified + extra + 2);
|
|
183
|
+
return { score, decisive, paragraphs: paragraphs.length, positive, negative, hits, regular };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* ------------------------------------------------------------------ config -- */
|
|
187
|
+
|
|
188
|
+
const DEFAULT_CONFIG = Object.freeze({
|
|
189
|
+
// No default: the probe prompt is the user's to choose.
|
|
190
|
+
prompt: '',
|
|
191
|
+
concurrency: 2,
|
|
192
|
+
provider: 'deepseek-official',
|
|
193
|
+
model: 'deepseek-v4-pro',
|
|
194
|
+
reasoningEffort: 'high',
|
|
195
|
+
folder: path.join(os.homedir(), 'rollout-scout'),
|
|
196
|
+
// Used only when no decisive opening has appeared: discard at/below
|
|
197
|
+
// discardBelow, keep at/above keepAbove, and act on neither until
|
|
198
|
+
// `minOpenings` paragraph openings have actually been classified.
|
|
199
|
+
discardBelow: 0.35,
|
|
200
|
+
keepAbove: 0.7,
|
|
201
|
+
minOpenings: 4,
|
|
202
|
+
// Give up on a probe that has opened this many paragraphs without a single
|
|
203
|
+
// positive opening — the "nothing promising ever showed up" case.
|
|
204
|
+
paragraphWindow: 10,
|
|
205
|
+
// Pause launching after the first confident catch, so the run can be
|
|
206
|
+
// resumed rather than restarted. Off by default: fishing usually wants to
|
|
207
|
+
// keep going past one hit.
|
|
208
|
+
autoPauseOnMatch: false,
|
|
209
|
+
// Discard a chain-of-thought that is thinking in Chinese, whatever the
|
|
210
|
+
// score — but only when Chinese dominates it, not when it merely quotes.
|
|
211
|
+
discardChinese: true,
|
|
212
|
+
chineseShare: 0.8,
|
|
213
|
+
// Delete probes judged as the old model (session log removed from disk).
|
|
214
|
+
autoDelete: false,
|
|
215
|
+
// Discard when streaming TPS exceeds this value (chunks / sec).
|
|
216
|
+
discardAboveTps: false,
|
|
217
|
+
maxTps: 60,
|
|
218
|
+
// Discard when first-token latency is below this threshold (too fast, in seconds).
|
|
219
|
+
discardBelowTtft: false,
|
|
220
|
+
minTtft: 2.0,
|
|
221
|
+
// Probe conversations are named from the host, and the name lands in the
|
|
222
|
+
// user's sidebar, so it follows the console's display language.
|
|
223
|
+
locale: 'en',
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// The probe folder is a workspace cwd the user types in, and `delete-all`
|
|
227
|
+
// removes every session attached to it. Pointing it at the harness state
|
|
228
|
+
// directory or at a home/root path would put unrelated conversations — or
|
|
229
|
+
// unrelated files — inside that blast radius, so those are refused outright.
|
|
230
|
+
const DSH_HOME = path.resolve(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'));
|
|
231
|
+
|
|
232
|
+
function isInside(child, parent) {
|
|
233
|
+
const rel = path.relative(parent, child);
|
|
234
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function assertSafeFolder(folder) {
|
|
238
|
+
const resolved = path.resolve(folder);
|
|
239
|
+
if (path.dirname(resolved) === resolved) {
|
|
240
|
+
throw new TypeError('folder 不能是磁盘根目录 / folder must not be a filesystem root');
|
|
241
|
+
}
|
|
242
|
+
if (resolved === path.resolve(os.homedir())) {
|
|
243
|
+
throw new TypeError('folder 不能是用户主目录 / folder must not be the home directory');
|
|
244
|
+
}
|
|
245
|
+
if (isInside(resolved, DSH_HOME) || isInside(DSH_HOME, resolved)) {
|
|
246
|
+
throw new TypeError(`folder 不能位于 ${DSH_HOME} 内 / folder must be outside ${DSH_HOME}`);
|
|
247
|
+
}
|
|
248
|
+
return resolved;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function sanitizeConfig(raw) {
|
|
252
|
+
const source = typeof raw === 'object' && raw !== null ? raw : {};
|
|
253
|
+
const config = { ...DEFAULT_CONFIG };
|
|
254
|
+
if (typeof source.prompt === 'string') config.prompt = source.prompt;
|
|
255
|
+
if (Number.isInteger(source.concurrency)) config.concurrency = Math.min(6, Math.max(1, source.concurrency));
|
|
256
|
+
if (typeof source.provider === 'string' && source.provider !== '') config.provider = source.provider;
|
|
257
|
+
if (typeof source.model === 'string' && source.model !== '') config.model = source.model;
|
|
258
|
+
if (['default', 'off', 'high', 'max'].includes(source.reasoningEffort)) config.reasoningEffort = source.reasoningEffort;
|
|
259
|
+
if (typeof source.folder === 'string' && source.folder.trim() !== '') config.folder = source.folder.trim();
|
|
260
|
+
if (Number.isFinite(source.discardBelow)) config.discardBelow = Math.min(0.9, Math.max(0.05, source.discardBelow));
|
|
261
|
+
if (Number.isFinite(source.keepAbove)) config.keepAbove = Math.min(0.99, Math.max(0.5, source.keepAbove));
|
|
262
|
+
if (Number.isInteger(source.minOpenings)) config.minOpenings = Math.min(40, Math.max(1, source.minOpenings));
|
|
263
|
+
if (Number.isInteger(source.paragraphWindow)) config.paragraphWindow = Math.min(200, Math.max(2, source.paragraphWindow));
|
|
264
|
+
if (config.keepAbove <= config.discardBelow) {
|
|
265
|
+
throw new TypeError('keepAbove 必须大于 discardBelow / keepAbove must exceed discardBelow');
|
|
266
|
+
}
|
|
267
|
+
if (typeof source.autoPauseOnMatch === 'boolean') config.autoPauseOnMatch = source.autoPauseOnMatch;
|
|
268
|
+
if (typeof source.discardChinese === 'boolean') config.discardChinese = source.discardChinese;
|
|
269
|
+
if (Number.isFinite(source.chineseShare)) config.chineseShare = Math.min(1, Math.max(0.5, source.chineseShare));
|
|
270
|
+
if (typeof source.autoDelete === 'boolean') config.autoDelete = source.autoDelete;
|
|
271
|
+
if (typeof source.discardAboveTps === 'boolean') config.discardAboveTps = source.discardAboveTps;
|
|
272
|
+
if (Number.isFinite(source.maxTps)) config.maxTps = Math.min(300, Math.max(1, source.maxTps));
|
|
273
|
+
if (typeof source.discardBelowTtft === 'boolean') config.discardBelowTtft = source.discardBelowTtft;
|
|
274
|
+
if (Number.isFinite(source.minTtft)) config.minTtft = Math.min(60, Math.max(0.1, source.minTtft));
|
|
275
|
+
if (source.locale === 'zh' || source.locale === 'en') config.locale = source.locale;
|
|
276
|
+
if (!path.isAbsolute(config.folder)) throw new TypeError('folder 必须是绝对路径 / folder must be an absolute path');
|
|
277
|
+
config.folder = assertSafeFolder(config.folder);
|
|
278
|
+
return config;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/* ------------------------------------------------------------------- state -- */
|
|
282
|
+
|
|
283
|
+
const HISTORY_LIMIT = 120;
|
|
284
|
+
const WATCHDOG_MS = 240_000;
|
|
285
|
+
/** Fade on the card, then cancel. Hover or click during the fade rescues it. */
|
|
286
|
+
const FADE_MS = 3_200;
|
|
287
|
+
// A probe that fails before it ever streams frees its slot immediately, so
|
|
288
|
+
// pump() launches a replacement that fails the same way. With a provider
|
|
289
|
+
// down or the folder unwritable that is an unbounded launch storm, so the
|
|
290
|
+
// run halts itself after this many failures with no successful start between.
|
|
291
|
+
const LAUNCH_FAILURE_LIMIT = 3;
|
|
292
|
+
|
|
293
|
+
// The host context, captured by `apply` so read-only lookups (settings, the
|
|
294
|
+
// live session store) do not have to be threaded through every operation.
|
|
295
|
+
let host = null;
|
|
296
|
+
|
|
297
|
+
const state = {
|
|
298
|
+
running: false,
|
|
299
|
+
// Launching stopped but the run is resumable; distinct from never started.
|
|
300
|
+
paused: false,
|
|
301
|
+
config: { ...DEFAULT_CONFIG },
|
|
302
|
+
attempts: [],
|
|
303
|
+
// Probes launched in the current run; reset by start() for the stat.
|
|
304
|
+
launched: 0,
|
|
305
|
+
// Never reset: ids must stay unique across runs or history collides.
|
|
306
|
+
sequence: 0,
|
|
307
|
+
note: null,
|
|
308
|
+
// Consecutive launches that threw before reaching 'streaming'.
|
|
309
|
+
launchFailures: 0,
|
|
310
|
+
// Set with note 'launch-failed', so the console can show what broke.
|
|
311
|
+
lastError: null,
|
|
312
|
+
// By id, not by attempt: the attempt list does not survive a plugin reload
|
|
313
|
+
// and the folder sweep that runs afterwards has to know what to skip.
|
|
314
|
+
protectedIds: new Set(),
|
|
315
|
+
orphans: { live: 0, cold: 0, at: 0 },
|
|
316
|
+
// What the last pause and the last sweep accounted for, for the console.
|
|
317
|
+
culled: 0,
|
|
318
|
+
reaped: 0,
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
/* ------------------------------------------------------- durable promises -- */
|
|
322
|
+
|
|
323
|
+
// The protected set is the one piece of plugin state that MUST outlive the
|
|
324
|
+
// process. Everything else can be rebuilt by looking at the folder, but
|
|
325
|
+
// "never touch this one" cannot be re-derived from anything on disk — and the
|
|
326
|
+
// sweep that cleans up after a plugin reload walks the folder, so without a
|
|
327
|
+
// durable record it would take the catch with it.
|
|
328
|
+
//
|
|
329
|
+
// It rides with the probe folder rather than with the plugin, because that is
|
|
330
|
+
// what it describes: repoint the scout somewhere new and the old folder keeps
|
|
331
|
+
// its own promises.
|
|
332
|
+
const STATE_FILE = '.rollout-scout.json';
|
|
333
|
+
const STATE_VERSION = 1;
|
|
334
|
+
|
|
335
|
+
function stateFilePath(folder) {
|
|
336
|
+
return path.join(folder, STATE_FILE);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function loadPromises(folder) {
|
|
340
|
+
state.protectedIds = new Set();
|
|
341
|
+
let raw;
|
|
342
|
+
try {
|
|
343
|
+
raw = JSON.parse(await fs.readFile(stateFilePath(folder), 'utf8'));
|
|
344
|
+
} catch (e) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (typeof raw !== 'object' || raw === null || raw.version !== STATE_VERSION) return;
|
|
348
|
+
if (Array.isArray(raw.protected)) {
|
|
349
|
+
for (const id of raw.protected) if (typeof id === 'string') state.protectedIds.add(id);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Best-effort: a promise that fails to persist is still honoured in memory. */
|
|
354
|
+
async function savePromises(folder) {
|
|
355
|
+
const body = JSON.stringify({
|
|
356
|
+
version: STATE_VERSION,
|
|
357
|
+
protected: [...state.protectedIds],
|
|
358
|
+
}, null, 2);
|
|
359
|
+
try {
|
|
360
|
+
await fs.mkdir(folder, { recursive: true });
|
|
361
|
+
await fs.writeFile(stateFilePath(folder), `${body}\n`);
|
|
362
|
+
} catch (e) {}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/* ------------------------------------------------------------- protection -- */
|
|
366
|
+
|
|
367
|
+
/** Fixed in place by the user; the classifier may not revise it. */
|
|
368
|
+
function settled(attempt) {
|
|
369
|
+
return attempt.protectedCatch
|
|
370
|
+
|| attempt.pinned
|
|
371
|
+
|| (attempt.sessionId !== null && state.protectedIds.has(attempt.sessionId));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Membership test for every bulk stop/delete cohort. */
|
|
375
|
+
function sweepable(attempt) {
|
|
376
|
+
return !settled(attempt) && attempt.verdict !== 'rollout';
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Session ids outside every cohort, including ones with no attempt left. */
|
|
380
|
+
function keptIds() {
|
|
381
|
+
const ids = new Set(state.protectedIds);
|
|
382
|
+
for (const attempt of state.attempts) {
|
|
383
|
+
if (attempt.sessionId !== null && !sweepable(attempt)) ids.add(attempt.sessionId);
|
|
384
|
+
}
|
|
385
|
+
return ids;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function markProtected(attempt) {
|
|
389
|
+
attempt.protectedCatch = true;
|
|
390
|
+
if (attempt.sessionId === null) return Promise.resolve();
|
|
391
|
+
state.protectedIds.add(attempt.sessionId);
|
|
392
|
+
return savePromises(state.config.folder);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function unmarkProtected(attempt) {
|
|
396
|
+
attempt.protectedCatch = false;
|
|
397
|
+
if (attempt.sessionId === null) return Promise.resolve();
|
|
398
|
+
state.protectedIds.delete(attempt.sessionId);
|
|
399
|
+
return savePromises(state.config.folder);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function publicAttempt(attempt) {
|
|
403
|
+
return {
|
|
404
|
+
id: attempt.id,
|
|
405
|
+
sessionId: attempt.sessionId,
|
|
406
|
+
status: attempt.status,
|
|
407
|
+
verdict: attempt.verdict,
|
|
408
|
+
score: attempt.score,
|
|
409
|
+
decisive: attempt.decisive,
|
|
410
|
+
reason: attempt.reason,
|
|
411
|
+
paragraphs: attempt.paragraphs,
|
|
412
|
+
positive: attempt.positive,
|
|
413
|
+
negative: attempt.negative,
|
|
414
|
+
hits: attempt.hits,
|
|
415
|
+
chinese: attempt.chinese,
|
|
416
|
+
chars: attempt.reasoning.length,
|
|
417
|
+
startedAt: attempt.startedAt,
|
|
418
|
+
endedAt: attempt.endedAt,
|
|
419
|
+
deleted: attempt.deleted,
|
|
420
|
+
error: attempt.error,
|
|
421
|
+
preview: attempt.reasoning.slice(0, 160),
|
|
422
|
+
regular: !!attempt.regular,
|
|
423
|
+
pauses: attempt.pauses || 0,
|
|
424
|
+
pinned: !!attempt.pinned,
|
|
425
|
+
held: !!attempt.held,
|
|
426
|
+
tps: typeof attempt.tps === 'number' ? attempt.tps : null,
|
|
427
|
+
ttft: typeof attempt.ttft === 'number' ? attempt.ttft : null,
|
|
428
|
+
protected: settled(attempt),
|
|
429
|
+
kept: !sweepable(attempt),
|
|
430
|
+
title: attempt.title ?? null,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* What DSH Desktop will do with a finished turn right now. `registered` is
|
|
436
|
+
* false on a web-only harness, where the namespace nobody registered cannot
|
|
437
|
+
* be read or written and the whole question is moot.
|
|
438
|
+
*/
|
|
439
|
+
function notificationState() {
|
|
440
|
+
let value;
|
|
441
|
+
try { value = host?.get('settings')?.get(DESKTOP_NOTIFICATIONS_NS); } catch (e) {}
|
|
442
|
+
if (value === undefined || value === null || typeof value !== 'object') {
|
|
443
|
+
return { registered: false, enabled: false, onTurnCompletion: false };
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
registered: true,
|
|
447
|
+
enabled: value.enabled !== false,
|
|
448
|
+
onTurnCompletion: value.notifyOnTurnCompletion !== false,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** The console's "turn them off" button. */
|
|
453
|
+
function muteNotifications(ctx) {
|
|
454
|
+
const settings = ctx.get('settings');
|
|
455
|
+
if (settings === undefined) throw new Error('设置服务不可用 / settings service unavailable');
|
|
456
|
+
return settings.update(DESKTOP_NOTIFICATIONS_NS, { enabled: false }).then(publicState);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function publicState() {
|
|
460
|
+
return {
|
|
461
|
+
running: state.running,
|
|
462
|
+
paused: state.paused,
|
|
463
|
+
config: state.config,
|
|
464
|
+
launched: state.launched,
|
|
465
|
+
note: state.note,
|
|
466
|
+
lastError: state.lastError,
|
|
467
|
+
active: state.attempts.filter((a) => isLive(a)).length,
|
|
468
|
+
// Live probes a delete would have to wait on; a kept one never is.
|
|
469
|
+
blocking: state.attempts.filter(sweepable).filter(isLive).length,
|
|
470
|
+
attempts: state.attempts.map(publicAttempt),
|
|
471
|
+
notifications: notificationState(),
|
|
472
|
+
orphans: state.orphans,
|
|
473
|
+
protectedCount: state.protectedIds.size,
|
|
474
|
+
culled: state.culled,
|
|
475
|
+
reaped: state.reaped,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/* ---------------------------------------------------------------- attempts -- */
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* The probe prompt, marked as plugin-sourced. DSH Desktop notifies only for a
|
|
483
|
+
* turn a person opened: it arms on a `user/message` whose `source.kind` is
|
|
484
|
+
* `user` and fires on the matching `turn/end`. The role stays `user` and
|
|
485
|
+
* `followup` never reads the source, so the turn and the request are
|
|
486
|
+
* unchanged — the prompt just is not mistaken for typing.
|
|
487
|
+
*/
|
|
488
|
+
function probeMessage(text) {
|
|
489
|
+
return Object.freeze({
|
|
490
|
+
id: crypto.randomUUID(),
|
|
491
|
+
role: 'user',
|
|
492
|
+
content: Object.freeze([Object.freeze({ type: 'text', text })]),
|
|
493
|
+
source: Object.freeze({ kind: 'plugin', plugin: PLUGIN_SOURCE }),
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// A plugin-sourced prompt bypasses the automatic titler — which also saves it
|
|
498
|
+
// a small-model call per session — so the plugin names probes itself. A catch
|
|
499
|
+
// is renamed again on the way out: it is the one conversation the user has to
|
|
500
|
+
// be able to pick out of a sidebar full of probes, so it leads with a mark and
|
|
501
|
+
// carries its score.
|
|
502
|
+
const TITLES = {
|
|
503
|
+
en: {
|
|
504
|
+
probe: (n) => `Rollout probe ${n}`,
|
|
505
|
+
catch: (n, pct) => `★ Rollout catch ${n} · ${pct}%`,
|
|
506
|
+
},
|
|
507
|
+
zh: {
|
|
508
|
+
probe: (n) => `灰度探测 ${n}`,
|
|
509
|
+
catch: (n, pct) => `★ 灰度命中 ${n} · ${pct}%`,
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* `sessionTitle.rename` needs the live session object and pins the title
|
|
515
|
+
* against later automatic generation. Throws for a session the store no
|
|
516
|
+
* longer holds, which is every probe from before a reload — so the explicit
|
|
517
|
+
* rename action reports that rather than swallowing it.
|
|
518
|
+
*/
|
|
519
|
+
function renameSession(ctx, sessionId, title) {
|
|
520
|
+
const session = ctx.sessions?.get(sessionId);
|
|
521
|
+
if (session === undefined) throw new Error('会话已不在内存中 / session is no longer live');
|
|
522
|
+
const service = ctx.get('sessionTitle');
|
|
523
|
+
if (service === undefined) throw new Error('标题服务不可用 / session title service unavailable');
|
|
524
|
+
service.rename(session, title);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function titleAttempt(ctx, attempt, stage) {
|
|
528
|
+
const strings = TITLES[state.config.locale] ?? TITLES.en;
|
|
529
|
+
const title = stage === 'catch'
|
|
530
|
+
? strings.catch(attempt.id, Math.round(attempt.score * 100))
|
|
531
|
+
: strings.probe(attempt.id);
|
|
532
|
+
try { renameSession(ctx, attempt.sessionId, title); } catch (e) {}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function isLive(attempt) {
|
|
536
|
+
if (attempt.closed) return false;
|
|
537
|
+
return attempt.status === 'starting'
|
|
538
|
+
|| attempt.status === 'streaming'
|
|
539
|
+
|| attempt.status === 'kept-streaming'
|
|
540
|
+
|| attempt.status === 'pending-discard';
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Probes the launch loop is still waiting on. A kept one is a result the user
|
|
545
|
+
* owns rather than a slot in flight — and since nothing may cancel it, counting
|
|
546
|
+
* it here would let N catches stall a run at concurrency N for good.
|
|
547
|
+
*/
|
|
548
|
+
function activeCount() {
|
|
549
|
+
let n = 0;
|
|
550
|
+
for (const a of state.attempts) {
|
|
551
|
+
if (isLive(a) && sweepable(a)) n += 1;
|
|
552
|
+
}
|
|
553
|
+
return n;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function anyCatch() {
|
|
557
|
+
return state.attempts.some((a) => a.verdict === 'rollout');
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Keep the concurrency slots full for as long as the run is active. */
|
|
561
|
+
function pump(ctx) {
|
|
562
|
+
if (!state.running) return;
|
|
563
|
+
while (state.running && activeCount() < state.config.concurrency) {
|
|
564
|
+
state.launched += 1;
|
|
565
|
+
state.sequence += 1;
|
|
566
|
+
const attempt = {
|
|
567
|
+
id: state.sequence,
|
|
568
|
+
sessionId: null,
|
|
569
|
+
status: 'starting',
|
|
570
|
+
verdict: null,
|
|
571
|
+
score: 0.5,
|
|
572
|
+
decisive: null,
|
|
573
|
+
reason: null,
|
|
574
|
+
paragraphs: 0,
|
|
575
|
+
positive: 0,
|
|
576
|
+
negative: 0,
|
|
577
|
+
hits: {},
|
|
578
|
+
chinese: false,
|
|
579
|
+
reasoning: '',
|
|
580
|
+
startedAt: Date.now(),
|
|
581
|
+
promptSentAt: null,
|
|
582
|
+
firstChunkAt: null,
|
|
583
|
+
chunkCount: 0,
|
|
584
|
+
tps: null,
|
|
585
|
+
ttft: null,
|
|
586
|
+
endedAt: null,
|
|
587
|
+
deleted: false,
|
|
588
|
+
error: null,
|
|
589
|
+
handle: null,
|
|
590
|
+
decided: false,
|
|
591
|
+
closed: false,
|
|
592
|
+
streamed: false,
|
|
593
|
+
watchdog: null,
|
|
594
|
+
pauses: 0,
|
|
595
|
+
lastChunkAt: null,
|
|
596
|
+
burstChars: 0,
|
|
597
|
+
pinned: false,
|
|
598
|
+
held: false,
|
|
599
|
+
fadeTimer: null,
|
|
600
|
+
};
|
|
601
|
+
state.attempts.unshift(attempt);
|
|
602
|
+
if (state.attempts.length > HISTORY_LIMIT) {
|
|
603
|
+
for (const dropped of state.attempts.slice(HISTORY_LIMIT)) clearFade(dropped);
|
|
604
|
+
state.attempts.length = HISTORY_LIMIT;
|
|
605
|
+
}
|
|
606
|
+
launch(ctx, attempt).catch((error) => {
|
|
607
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
608
|
+
attempt.status = 'error';
|
|
609
|
+
attempt.error = message;
|
|
610
|
+
attempt.endedAt = Date.now();
|
|
611
|
+
attempt.closed = true;
|
|
612
|
+
// Only a launch that never reached 'streaming' counts towards the
|
|
613
|
+
// breaker: a failure after the turn started is the probe's problem,
|
|
614
|
+
// not a sign that launching itself is broken.
|
|
615
|
+
if (!attempt.streamed) {
|
|
616
|
+
state.launchFailures += 1;
|
|
617
|
+
if (state.launchFailures >= LAUNCH_FAILURE_LIMIT) {
|
|
618
|
+
state.running = false;
|
|
619
|
+
state.paused = true;
|
|
620
|
+
state.note = 'launch-failed';
|
|
621
|
+
state.lastError = message;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
settle(ctx);
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async function launch(ctx, attempt) {
|
|
630
|
+
const config = state.config;
|
|
631
|
+
await fs.mkdir(config.folder, { recursive: true });
|
|
632
|
+
const workspace = (await ctx.workspaceRegistry.resolveByPath(config.folder))
|
|
633
|
+
?? (await ctx.workspaceRegistry.create(config.folder, 'Rollout Scout'));
|
|
634
|
+
|
|
635
|
+
const selection = {
|
|
636
|
+
provider: config.provider,
|
|
637
|
+
model: config.model,
|
|
638
|
+
...(config.reasoningEffort !== 'default' ? { reasoningEffort: config.reasoningEffort } : {}),
|
|
639
|
+
};
|
|
640
|
+
let installModelSelection = null;
|
|
641
|
+
try { ({ installModelSelection } = await import('@deepseek-ai/dsh-agent')); } catch (e) {}
|
|
642
|
+
|
|
643
|
+
const sessionId = `session-${crypto.randomUUID()}`;
|
|
644
|
+
const handle = await ctx.agents.create({
|
|
645
|
+
sessionId,
|
|
646
|
+
meta: { cwd: workspace.path },
|
|
647
|
+
agentOptions: { provider: selection.provider, model: selection.model },
|
|
648
|
+
setup: (agentCtx) => {
|
|
649
|
+
if (installModelSelection) {
|
|
650
|
+
installModelSelection(agentCtx, { current: selection, assembled: undefined });
|
|
651
|
+
}
|
|
652
|
+
// Scoped: only this agent's events reach this listener.
|
|
653
|
+
agentCtx.on('session/event', (session, event) => onSessionEvent(ctx, attempt, event));
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
attempt.sessionId = sessionId;
|
|
657
|
+
attempt.handle = handle;
|
|
658
|
+
// A force stop that landed while the agent was being created: never prompt.
|
|
659
|
+
if (attempt.forced) {
|
|
660
|
+
attempt.handle = null;
|
|
661
|
+
finish(ctx, attempt);
|
|
662
|
+
release(handle);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
try { await workspace.attachSession(sessionId); } catch (e) {}
|
|
666
|
+
|
|
667
|
+
attempt.status = 'streaming';
|
|
668
|
+
attempt.streamed = true;
|
|
669
|
+
// One probe that got as far as its first turn clears the breaker.
|
|
670
|
+
state.launchFailures = 0;
|
|
671
|
+
attempt.watchdog = setTimeout(() => {
|
|
672
|
+
if (!attempt.closed && sweepable(attempt)) {
|
|
673
|
+
attempt.error = 'watchdog timeout';
|
|
674
|
+
try { handle.agent.cancel({ kind: 'user' }, { keepInbox: false }); } catch (e) {}
|
|
675
|
+
}
|
|
676
|
+
}, WATCHDOG_MS);
|
|
677
|
+
titleAttempt(ctx, attempt, 'probe');
|
|
678
|
+
attempt.promptSentAt = Date.now();
|
|
679
|
+
handle.agent.followup(probeMessage(config.prompt));
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function onSessionEvent(ctx, attempt, event) {
|
|
683
|
+
if (attempt.closed) return;
|
|
684
|
+
if (event.type === 'assistant/chunk') {
|
|
685
|
+
const chunk = event.data.chunk;
|
|
686
|
+
if (chunk.type === 'reasoning-delta') {
|
|
687
|
+
const now = Date.now();
|
|
688
|
+
const text = chunk.text || '';
|
|
689
|
+
if (attempt.firstChunkAt === null) {
|
|
690
|
+
attempt.firstChunkAt = now;
|
|
691
|
+
if (attempt.promptSentAt !== null) {
|
|
692
|
+
attempt.ttft = Math.round((now - attempt.promptSentAt) / 100) / 10;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
attempt.chunkCount = (attempt.chunkCount || 0) + 1;
|
|
696
|
+
const elapsedSec = (now - attempt.firstChunkAt) / 1000;
|
|
697
|
+
if (attempt.chunkCount >= 8 && elapsedSec >= 0.4) {
|
|
698
|
+
attempt.tps = Math.round((attempt.chunkCount / elapsedSec) * 10) / 10;
|
|
699
|
+
}
|
|
700
|
+
if (attempt.lastChunkAt !== null && text.length > 0
|
|
701
|
+
&& now - attempt.lastChunkAt >= PAUSE_MS
|
|
702
|
+
&& attempt.burstChars >= BURST_MIN_CHARS) {
|
|
703
|
+
attempt.pauses += 1;
|
|
704
|
+
attempt.burstChars = 0;
|
|
705
|
+
}
|
|
706
|
+
attempt.lastChunkAt = now;
|
|
707
|
+
attempt.burstChars += text.length;
|
|
708
|
+
attempt.reasoning += text;
|
|
709
|
+
evaluate(ctx, attempt);
|
|
710
|
+
}
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (event.type === 'turn/end') {
|
|
714
|
+
finish(ctx, attempt);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* The agent handle's disposer is the exact Cordis effect disposer, and those
|
|
720
|
+
* are single-shot: a repeat call returns undefined instead of a promise, so it
|
|
721
|
+
* cannot be chained onto. The owner fiber can also have triggered it already.
|
|
722
|
+
*/
|
|
723
|
+
function release(handle) {
|
|
724
|
+
Promise.resolve(handle.dispose()).catch(() => {});
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function clearFade(attempt) {
|
|
728
|
+
if (attempt.fadeTimer) {
|
|
729
|
+
clearTimeout(attempt.fadeTimer);
|
|
730
|
+
attempt.fadeTimer = null;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function clearWatchdog(attempt) {
|
|
735
|
+
if (attempt.watchdog) {
|
|
736
|
+
clearTimeout(attempt.watchdog);
|
|
737
|
+
attempt.watchdog = null;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** How long a cancelled probe is given to deliver its `turn/end`. */
|
|
742
|
+
const DISCARD_GRACE_MS = 10_000;
|
|
743
|
+
|
|
744
|
+
function commitDiscard(ctx, attempt) {
|
|
745
|
+
clearFade(attempt);
|
|
746
|
+
if (settled(attempt)) return;
|
|
747
|
+
attempt.decided = true;
|
|
748
|
+
attempt.verdict = 'old';
|
|
749
|
+
if (attempt.closed) {
|
|
750
|
+
attempt.status = 'discarded';
|
|
751
|
+
attempt.endedAt = attempt.endedAt ?? Date.now();
|
|
752
|
+
if (state.config.autoDelete) {
|
|
753
|
+
deleteAttempt(ctx, attempt).catch((error) => {
|
|
754
|
+
attempt.error = error instanceof Error ? error.message : String(error);
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
settle(ctx);
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
attempt.status = 'discarding';
|
|
761
|
+
try { attempt.handle.agent.cancel({ kind: 'user' }, { keepInbox: false }); } catch (e) {}
|
|
762
|
+
// The long watchdog is pointless now — the turn is already cancelled. Swap
|
|
763
|
+
// it for a short reaper so a cancel that never produces `turn/end` cannot
|
|
764
|
+
// strand the attempt in 'discarding' with its agent handle still open.
|
|
765
|
+
clearWatchdog(attempt);
|
|
766
|
+
attempt.watchdog = setTimeout(() => {
|
|
767
|
+
attempt.watchdog = null;
|
|
768
|
+
if (!attempt.closed) finish(ctx, attempt);
|
|
769
|
+
}, DISCARD_GRACE_MS);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** Start the fade. The turn keeps running until the animation ends. */
|
|
773
|
+
function offerFade(ctx, attempt, reason) {
|
|
774
|
+
if (attempt.held || settled(attempt)) return;
|
|
775
|
+
if (attempt.status === 'pending-discard' || attempt.status === 'discarding') return;
|
|
776
|
+
attempt.decided = true;
|
|
777
|
+
attempt.verdict = 'old';
|
|
778
|
+
attempt.reason = reason;
|
|
779
|
+
attempt.status = 'pending-discard';
|
|
780
|
+
clearFade(attempt);
|
|
781
|
+
attempt.fadeTimer = setTimeout(() => {
|
|
782
|
+
attempt.fadeTimer = null;
|
|
783
|
+
if (attempt.pinned || attempt.held) return;
|
|
784
|
+
if (attempt.status !== 'pending-discard') return;
|
|
785
|
+
commitDiscard(ctx, attempt);
|
|
786
|
+
}, FADE_MS);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function keep(attempt, reason) {
|
|
790
|
+
clearFade(attempt);
|
|
791
|
+
attempt.decided = true;
|
|
792
|
+
attempt.verdict = 'rollout';
|
|
793
|
+
attempt.reason = reason;
|
|
794
|
+
attempt.status = 'kept-streaming';
|
|
795
|
+
if (state.config.autoPauseOnMatch) {
|
|
796
|
+
state.running = false;
|
|
797
|
+
state.paused = true;
|
|
798
|
+
state.note = 'hit';
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function retractKeep(ctx, attempt, reason) {
|
|
803
|
+
if (attempt.held || settled(attempt)) return;
|
|
804
|
+
const pausedForHit = state.paused && state.note === 'hit';
|
|
805
|
+
offerFade(ctx, attempt, reason);
|
|
806
|
+
if (pausedForHit && !state.attempts.some((a) => a.verdict === 'rollout')) {
|
|
807
|
+
state.running = true;
|
|
808
|
+
state.paused = false;
|
|
809
|
+
state.note = null;
|
|
810
|
+
pump(ctx);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function findAttempt(id) {
|
|
815
|
+
const attempt = state.attempts.find((a) => a.id === Number(id));
|
|
816
|
+
if (attempt === undefined) throw new TypeError('找不到该探测 / probe not found');
|
|
817
|
+
return attempt;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* The user taking a conversation out of this plugin's reach for good. Unlike
|
|
822
|
+
* a pin — which is a hover-scale rescue that a later verdict can still walk
|
|
823
|
+
* back — this is durable, survives a plugin reload, and is cleared only by
|
|
824
|
+
* `unprotect`.
|
|
825
|
+
*/
|
|
826
|
+
async function protectAttempt(id) {
|
|
827
|
+
const attempt = findAttempt(id);
|
|
828
|
+
clearFade(attempt);
|
|
829
|
+
clearWatchdog(attempt);
|
|
830
|
+
await markProtected(attempt);
|
|
831
|
+
if (attempt.status === 'pending-discard' || attempt.status === 'discarding') {
|
|
832
|
+
attempt.status = attempt.closed ? 'pinned' : 'streaming';
|
|
833
|
+
}
|
|
834
|
+
return publicState();
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** Name a catch yourself. Keeping it is implied — naming it means you want it. */
|
|
838
|
+
async function renameAttempt(ctx, id, title) {
|
|
839
|
+
const attempt = findAttempt(id);
|
|
840
|
+
const text = typeof title === 'string' ? title.trim() : '';
|
|
841
|
+
if (text === '') throw new TypeError('标题不能为空 / title must not be empty');
|
|
842
|
+
if (attempt.sessionId === null) throw new Error('该探测还没有会话 / probe has no session yet');
|
|
843
|
+
renameSession(ctx, attempt.sessionId, text);
|
|
844
|
+
attempt.title = text;
|
|
845
|
+
await markProtected(attempt);
|
|
846
|
+
return publicState();
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** Hand a kept conversation back to the ordinary rules. */
|
|
850
|
+
async function unprotectAttempt(id) {
|
|
851
|
+
const attempt = findAttempt(id);
|
|
852
|
+
await unmarkProtected(attempt);
|
|
853
|
+
return publicState();
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/** Click, or hover on a fading card: keep it running. */
|
|
857
|
+
function pinAttempt(id) {
|
|
858
|
+
const attempt = findAttempt(id);
|
|
859
|
+
clearFade(attempt);
|
|
860
|
+
attempt.pinned = true;
|
|
861
|
+
attempt.held = true;
|
|
862
|
+
if (attempt.closed) {
|
|
863
|
+
attempt.status = 'pinned';
|
|
864
|
+
} else if (attempt.status === 'pending-discard' || attempt.status === 'discarding') {
|
|
865
|
+
attempt.status = 'streaming';
|
|
866
|
+
}
|
|
867
|
+
return publicState();
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/** Mouse entered: do not discard. Fading cards are rescued. */
|
|
871
|
+
function holdAttempt(id) {
|
|
872
|
+
const attempt = findAttempt(id);
|
|
873
|
+
attempt.held = true;
|
|
874
|
+
if (attempt.status === 'pending-discard') return pinAttempt(id);
|
|
875
|
+
return publicState();
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/** Mouse left. Pinned rows stay. Others may start fading. */
|
|
879
|
+
function releaseAttempt(ctx, id) {
|
|
880
|
+
const attempt = findAttempt(id);
|
|
881
|
+
attempt.held = false;
|
|
882
|
+
if (attempt.pinned || attempt.closed) return publicState();
|
|
883
|
+
if (attempt.decided && attempt.verdict === 'old'
|
|
884
|
+
&& attempt.status !== 'pending-discard' && attempt.status !== 'discarding') {
|
|
885
|
+
offerFade(ctx, attempt, attempt.reason);
|
|
886
|
+
}
|
|
887
|
+
return publicState();
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Live verdict, in priority order: a Chinese chain-of-thought, then the first
|
|
892
|
+
* decisive paragraph opening, then — when neither has appeared — the soft
|
|
893
|
+
* score, and finally the window rule for a probe that has opened many
|
|
894
|
+
* paragraphs without ever reading promising.
|
|
895
|
+
*/
|
|
896
|
+
function wantsDiscard(attempt, result, customConfig) {
|
|
897
|
+
const config = customConfig ?? state.config;
|
|
898
|
+
if (config.discardChinese && chineseShare(attempt.reasoning) >= config.chineseShare) {
|
|
899
|
+
return 'chinese';
|
|
900
|
+
}
|
|
901
|
+
if (config.discardBelowTtft && typeof attempt.ttft === 'number' && attempt.ttft < config.minTtft) {
|
|
902
|
+
return 'ttft_fast';
|
|
903
|
+
}
|
|
904
|
+
if (config.discardAboveTps && typeof attempt.tps === 'number' && attempt.tps > config.maxTps) {
|
|
905
|
+
return 'tps';
|
|
906
|
+
}
|
|
907
|
+
if (result.decisive === 'old') return 'decisive';
|
|
908
|
+
const openings = result.positive + result.negative;
|
|
909
|
+
if (openings >= config.minOpenings && attempt.score <= config.discardBelow) return 'score';
|
|
910
|
+
if (result.paragraphs >= config.paragraphWindow && result.positive === 0) return 'window';
|
|
911
|
+
return null;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function blendedScore(result, attempt) {
|
|
915
|
+
if (result.decisive === 'new') return 1;
|
|
916
|
+
if (result.decisive === 'old') return 0;
|
|
917
|
+
const pauseExtra = (attempt.pauses || 0) >= 1 ? 1 : 0;
|
|
918
|
+
if (pauseExtra === 0) return result.score;
|
|
919
|
+
const classified = result.positive + result.negative;
|
|
920
|
+
const extra = (result.regular ? 1 : 0) + pauseExtra;
|
|
921
|
+
return (result.positive + extra + 1) / (classified + extra + 2);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function evaluate(ctx, attempt, final) {
|
|
925
|
+
const result = classify(attempt.reasoning, final);
|
|
926
|
+
attempt.decisive = result.decisive;
|
|
927
|
+
attempt.paragraphs = result.paragraphs;
|
|
928
|
+
attempt.positive = result.positive;
|
|
929
|
+
attempt.negative = result.negative;
|
|
930
|
+
attempt.hits = result.hits;
|
|
931
|
+
attempt.regular = result.regular;
|
|
932
|
+
attempt.score = blendedScore(result, attempt);
|
|
933
|
+
const reject = wantsDiscard(attempt, result);
|
|
934
|
+
if (reject === 'chinese') attempt.chinese = true;
|
|
935
|
+
|
|
936
|
+
if (attempt.pinned || attempt.held) return;
|
|
937
|
+
|
|
938
|
+
if (attempt.decided) {
|
|
939
|
+
if (attempt.verdict === 'rollout' && reject) {
|
|
940
|
+
retractKeep(ctx, attempt, reject);
|
|
941
|
+
}
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
if (reject) {
|
|
945
|
+
offerFade(ctx, attempt, reject);
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
if (result.decisive === 'new') { keep(attempt, 'decisive'); return; }
|
|
949
|
+
const openings = result.positive + result.negative;
|
|
950
|
+
if (openings >= state.config.minOpenings && attempt.score >= state.config.keepAbove) {
|
|
951
|
+
keep(attempt, 'score');
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
// Summariser fingerprint: even paragraphs plus at least one stall
|
|
955
|
+
// between bursts, and some first-person-singular openings.
|
|
956
|
+
if (result.regular && (attempt.pauses || 0) >= 1 && result.positive >= 2 && result.decisive !== 'old') {
|
|
957
|
+
keep(attempt, 'shape');
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function finish(ctx, attempt) {
|
|
962
|
+
if (attempt.closed) return;
|
|
963
|
+
attempt.closed = true;
|
|
964
|
+
attempt.endedAt = Date.now();
|
|
965
|
+
clearWatchdog(attempt);
|
|
966
|
+
if (!attempt.forced) {
|
|
967
|
+
evaluate(ctx, attempt, true);
|
|
968
|
+
}
|
|
969
|
+
if (attempt.status === 'pending-discard') {
|
|
970
|
+
if (attempt.forced) {
|
|
971
|
+
clearFade(attempt);
|
|
972
|
+
attempt.status = 'discarded';
|
|
973
|
+
}
|
|
974
|
+
settle(ctx);
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
if (settled(attempt) && attempt.verdict !== 'rollout') {
|
|
978
|
+
attempt.status = 'pinned';
|
|
979
|
+
settle(ctx);
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
if (!attempt.decided) {
|
|
983
|
+
if (attempt.forced || attempt.error) {
|
|
984
|
+
attempt.verdict = 'unknown';
|
|
985
|
+
} else {
|
|
986
|
+
const openings = attempt.positive + attempt.negative;
|
|
987
|
+
if (openings >= state.config.minOpenings && attempt.score >= state.config.keepAbove) {
|
|
988
|
+
attempt.verdict = 'rollout';
|
|
989
|
+
attempt.reason = 'score';
|
|
990
|
+
} else if (attempt.held) {
|
|
991
|
+
attempt.verdict = 'old';
|
|
992
|
+
attempt.reason = attempt.positive === 0 ? 'window' : 'ended';
|
|
993
|
+
} else {
|
|
994
|
+
offerFade(ctx, attempt, attempt.positive === 0 ? 'window' : 'ended');
|
|
995
|
+
settle(ctx);
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
if (attempt.verdict === 'rollout') {
|
|
1001
|
+
attempt.status = 'kept';
|
|
1002
|
+
markProtected(attempt);
|
|
1003
|
+
titleAttempt(ctx, attempt, 'catch');
|
|
1004
|
+
if (state.config.autoPauseOnMatch) {
|
|
1005
|
+
state.running = false;
|
|
1006
|
+
state.paused = true;
|
|
1007
|
+
state.note = 'hit';
|
|
1008
|
+
}
|
|
1009
|
+
} else if (attempt.verdict === 'old' && !settled(attempt) && !attempt.held) {
|
|
1010
|
+
attempt.status = 'discarded';
|
|
1011
|
+
if (state.config.autoDelete) {
|
|
1012
|
+
deleteAttempt(ctx, attempt).catch((error) => {
|
|
1013
|
+
attempt.error = error instanceof Error ? error.message : String(error);
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
} else {
|
|
1017
|
+
attempt.status = attempt.error ? 'error' : (attempt.forced ? 'stopped' : (settled(attempt) ? 'pinned' : 'finished'));
|
|
1018
|
+
}
|
|
1019
|
+
settle(ctx);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/** A slot just freed up: try to refill it. */
|
|
1023
|
+
function settle(ctx) {
|
|
1024
|
+
pump(ctx);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Delete one session's log from disk. The harness gives each session its own
|
|
1029
|
+
* directory (…/sessions/<workspace>/<sessionId>/session.jsonl.zstd), so the
|
|
1030
|
+
* directory is what has to go — but that layout is not a contract, and a
|
|
1031
|
+
* recursive remove of the parent would take every sibling session with it if
|
|
1032
|
+
* the log ever became a flat file in a shared directory. The directory is
|
|
1033
|
+
* therefore only removed when it demonstrably belongs to this session;
|
|
1034
|
+
* otherwise just the log file goes.
|
|
1035
|
+
*/
|
|
1036
|
+
async function removeSessionLog(ctx, sessionId, headers, cwd) {
|
|
1037
|
+
// The listing is a convenience, not a requirement: `locate` is a pure path
|
|
1038
|
+
// computation over `{ id, cwd }`. Depending on it alone left behind the log
|
|
1039
|
+
// of any session it had not caught up with — and a log whose workspace slot
|
|
1040
|
+
// is gone is an ungrouped sidebar row with no Delete in its menu.
|
|
1041
|
+
const header = headers.find((h) => h.id === sessionId)
|
|
1042
|
+
?? (cwd === undefined ? undefined : { id: sessionId, cwd });
|
|
1043
|
+
if (header === undefined) return;
|
|
1044
|
+
let location;
|
|
1045
|
+
try { location = ctx.sessionPersistence.locate(header); } catch (e) { return; }
|
|
1046
|
+
if (location === undefined || typeof location.path !== 'string') return;
|
|
1047
|
+
const dir = path.dirname(location.path);
|
|
1048
|
+
if (path.basename(dir) === sessionId) {
|
|
1049
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
1050
|
+
} else {
|
|
1051
|
+
await fs.rm(location.path, { force: true });
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/** Remove a discarded probe entirely: live agent, workspace slot, on-disk log. */
|
|
1056
|
+
async function deleteAttempt(ctx, attempt) {
|
|
1057
|
+
const sessionId = attempt.sessionId;
|
|
1058
|
+
if (sessionId === null) return;
|
|
1059
|
+
const workspace = ctx.workspaceRegistry.list().find((w) => w.sessionIds.includes(sessionId));
|
|
1060
|
+
try { await workspace?.detachSession(sessionId); } catch (e) {}
|
|
1061
|
+
try { await attempt.handle?.dispose(); } catch (e) {}
|
|
1062
|
+
attempt.handle = null;
|
|
1063
|
+
let headers = [];
|
|
1064
|
+
try { headers = await ctx.sessionPersistence.list(); } catch (e) {}
|
|
1065
|
+
await removeSessionLog(ctx, sessionId, headers, state.config.folder);
|
|
1066
|
+
attempt.deleted = true;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* Run the labelled corpus through the shipped decision path under a given
|
|
1071
|
+
* config. The detector is otherwise unfalsifiable from the console: every
|
|
1072
|
+
* probe so far has been discarded, and nothing on screen separates "this
|
|
1073
|
+
* account is on the old model" from "a threshold or a regex is broken and
|
|
1074
|
+
* nothing could ever be kept". This answers that in one line, for free, and
|
|
1075
|
+
* re-answers it whenever a threshold is edited.
|
|
1076
|
+
*
|
|
1077
|
+
* It calls `classify` and `wantsDiscard` — the same functions a live probe
|
|
1078
|
+
* goes through — rather than reimplementing the ladder, so a change to either
|
|
1079
|
+
* shows up here immediately.
|
|
1080
|
+
*/
|
|
1081
|
+
function selfCheck(rawConfig) {
|
|
1082
|
+
const config = sanitizeConfig({ ...rawConfig, prompt: 'x' });
|
|
1083
|
+
const results = FIXTURES.map((fixture) => {
|
|
1084
|
+
const result = classify(fixture.text, true);
|
|
1085
|
+
const attempt = {
|
|
1086
|
+
reasoning: fixture.text,
|
|
1087
|
+
score: result.score,
|
|
1088
|
+
pauses: 0,
|
|
1089
|
+
tps: null,
|
|
1090
|
+
ttft: null,
|
|
1091
|
+
};
|
|
1092
|
+
attempt.score = blendedScore(result, attempt);
|
|
1093
|
+
const reject = wantsDiscard(attempt, result, config);
|
|
1094
|
+
const openings = result.positive + result.negative;
|
|
1095
|
+
const verdict = reject !== null ? 'old'
|
|
1096
|
+
: result.decisive === 'new' ? 'rollout'
|
|
1097
|
+
: openings >= config.minOpenings && attempt.score >= config.keepAbove ? 'rollout'
|
|
1098
|
+
: 'old';
|
|
1099
|
+
return {
|
|
1100
|
+
id: fixture.id,
|
|
1101
|
+
title: fixture.title,
|
|
1102
|
+
label: fixture.label,
|
|
1103
|
+
verdict,
|
|
1104
|
+
agrees: verdict === fixture.label,
|
|
1105
|
+
score: Math.round(attempt.score * 100),
|
|
1106
|
+
reason: reject ?? (verdict === 'rollout' ? (result.decisive === 'new' ? 'decisive' : 'score') : 'ended'),
|
|
1107
|
+
};
|
|
1108
|
+
});
|
|
1109
|
+
const rollout = results.filter((r) => r.label === 'rollout');
|
|
1110
|
+
return {
|
|
1111
|
+
total: results.length,
|
|
1112
|
+
agreed: results.filter((r) => r.agrees).length,
|
|
1113
|
+
rolloutTotal: rollout.length,
|
|
1114
|
+
rolloutKept: rollout.filter((r) => r.verdict === 'rollout').length,
|
|
1115
|
+
results,
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/* -------------------------------------------------------------- operations -- */
|
|
1120
|
+
|
|
1121
|
+
async function start(ctx, rawConfig) {
|
|
1122
|
+
if (state.running) throw new Error('已在运行 / already running');
|
|
1123
|
+
const previousFolder = state.config.folder;
|
|
1124
|
+
state.config = sanitizeConfig(rawConfig);
|
|
1125
|
+
if (state.config.prompt.trim() === '') {
|
|
1126
|
+
throw new TypeError('请先填写探测提示词 / enter a probe prompt first');
|
|
1127
|
+
}
|
|
1128
|
+
// Promises ride with the folder, so a run pointed somewhere new reads that
|
|
1129
|
+
// folder's own protected set rather than carrying the old one across.
|
|
1130
|
+
if (state.config.folder !== previousFolder || state.protectedIds.size === 0) {
|
|
1131
|
+
await loadPromises(state.config.folder);
|
|
1132
|
+
}
|
|
1133
|
+
state.running = true;
|
|
1134
|
+
state.paused = false;
|
|
1135
|
+
state.launched = 0;
|
|
1136
|
+
state.note = null;
|
|
1137
|
+
state.lastError = null;
|
|
1138
|
+
state.launchFailures = 0;
|
|
1139
|
+
// Probe numbers restart at 1 when the list is empty so a fresh run
|
|
1140
|
+
// after deleting sessions does not continue from 101.
|
|
1141
|
+
if (state.attempts.length === 0) state.sequence = 0;
|
|
1142
|
+
pump(ctx);
|
|
1143
|
+
return publicState();
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* Stop launching. Probes we are still unsure about run on to their own
|
|
1148
|
+
* verdicts — that is the whole reason pause is not force-stop — but a probe
|
|
1149
|
+
* already judged as the old model is cancelled here and now.
|
|
1150
|
+
*
|
|
1151
|
+
* Leaving those running was the expensive half of the old behaviour: pausing
|
|
1152
|
+
* is the user saying "stop spending", and a probe whose verdict is settled
|
|
1153
|
+
* has nothing left to tell us, so every token it draws afterwards is waste.
|
|
1154
|
+
* A fading card is committed immediately rather than being given the rest of
|
|
1155
|
+
* its animation, and a protected catch is never touched.
|
|
1156
|
+
*/
|
|
1157
|
+
function pause(ctx) {
|
|
1158
|
+
if (!state.running && !state.paused) return publicState();
|
|
1159
|
+
state.running = false;
|
|
1160
|
+
state.paused = true;
|
|
1161
|
+
state.note = 'paused';
|
|
1162
|
+
const done = state.attempts.filter(sweepable).filter(isLive).filter(
|
|
1163
|
+
(a) => (a.decided && a.verdict === 'old') || a.status === 'pending-discard');
|
|
1164
|
+
for (const attempt of done) {
|
|
1165
|
+
attempt.reason = attempt.reason ?? 'paused';
|
|
1166
|
+
commitDiscard(ctx, attempt);
|
|
1167
|
+
}
|
|
1168
|
+
state.culled = done.length;
|
|
1169
|
+
if (done.length > 0) state.note = 'paused-culled';
|
|
1170
|
+
return publicState();
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/** Resume launching under the config the run started with. */
|
|
1174
|
+
function resume(ctx) {
|
|
1175
|
+
if (state.running) return publicState();
|
|
1176
|
+
state.running = true;
|
|
1177
|
+
state.paused = false;
|
|
1178
|
+
state.note = null;
|
|
1179
|
+
state.lastError = null;
|
|
1180
|
+
// A resume after the breaker tripped is the user saying "try again".
|
|
1181
|
+
state.launchFailures = 0;
|
|
1182
|
+
pump(ctx);
|
|
1183
|
+
return publicState();
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* Stop launching AND abort every conversation still in flight. `cancel` is a
|
|
1188
|
+
* no-op when a probe has no active turn yet, so each agent is also disposed
|
|
1189
|
+
* and its attempt settled here rather than left waiting on a `turn/end` that
|
|
1190
|
+
* may never arrive.
|
|
1191
|
+
*/
|
|
1192
|
+
function forceStop(ctx) {
|
|
1193
|
+
state.running = false;
|
|
1194
|
+
state.paused = false;
|
|
1195
|
+
state.note = 'force-stopped';
|
|
1196
|
+
state.lastError = null;
|
|
1197
|
+
state.launchFailures = 0;
|
|
1198
|
+
for (const attempt of state.attempts.filter(sweepable)) {
|
|
1199
|
+
clearFade(attempt);
|
|
1200
|
+
if (attempt.closed) continue;
|
|
1201
|
+
// Marked even when the handle is not assigned yet: a probe still inside
|
|
1202
|
+
// launch() checks this flag as soon as its agent exists.
|
|
1203
|
+
attempt.forced = true;
|
|
1204
|
+
if (attempt.handle === null) continue;
|
|
1205
|
+
const handle = attempt.handle;
|
|
1206
|
+
attempt.handle = null;
|
|
1207
|
+
try { handle.agent.cancel({ kind: 'user' }, { keepInbox: false }); } catch (e) {}
|
|
1208
|
+
finish(ctx, attempt);
|
|
1209
|
+
release(handle);
|
|
1210
|
+
}
|
|
1211
|
+
return publicState();
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
async function clearHistory(ctx) {
|
|
1215
|
+
if (state.running) throw new Error('运行中不能清空 / cannot clear while running');
|
|
1216
|
+
// Named for what they hold rather than `keep`, which is the verdict
|
|
1217
|
+
// function one scope up.
|
|
1218
|
+
const retained = [];
|
|
1219
|
+
const dropped = [];
|
|
1220
|
+
for (const attempt of state.attempts) {
|
|
1221
|
+
if (isLive(attempt) || !sweepable(attempt)) {
|
|
1222
|
+
retained.push(attempt);
|
|
1223
|
+
} else {
|
|
1224
|
+
dropped.push(attempt);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
state.attempts = retained;
|
|
1228
|
+
for (const attempt of dropped) {
|
|
1229
|
+
try { await deleteAttempt(ctx, attempt); } catch (e) {}
|
|
1230
|
+
}
|
|
1231
|
+
if (state.attempts.length === 0) state.sequence = 0;
|
|
1232
|
+
return publicState();
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* Every probe conversation in the folder, from the three places one can hide:
|
|
1237
|
+
* a workspace slot (survives a `clear`), the live session store (survives a
|
|
1238
|
+
* plugin reload, since agents are owned by the harness), and the persistence
|
|
1239
|
+
* listing (survives both). Sessions are matched on their recorded `cwd` —
|
|
1240
|
+
* matching on log path finds nothing, because logs live under the harness
|
|
1241
|
+
* state directory rather than under the workspace.
|
|
1242
|
+
*/
|
|
1243
|
+
function probeSessionIds(ctx, folderNorm, headers, workspace) {
|
|
1244
|
+
const roots = new Set([folderNorm]);
|
|
1245
|
+
if (workspace !== undefined) {
|
|
1246
|
+
try { roots.add(path.resolve(workspace.path)); } catch (e) {}
|
|
1247
|
+
}
|
|
1248
|
+
const matches = (cwd) => {
|
|
1249
|
+
if (typeof cwd !== 'string') return false;
|
|
1250
|
+
try { return roots.has(path.resolve(cwd)); } catch (e) { return false; }
|
|
1251
|
+
};
|
|
1252
|
+
const ids = new Set();
|
|
1253
|
+
for (const id of workspace?.sessionIds ?? []) ids.add(id);
|
|
1254
|
+
for (const attempt of state.attempts) {
|
|
1255
|
+
if (attempt.sessionId) ids.add(attempt.sessionId);
|
|
1256
|
+
}
|
|
1257
|
+
try {
|
|
1258
|
+
for (const session of ctx.sessions?.list() ?? []) {
|
|
1259
|
+
if (matches(session.header?.cwd)) ids.add(session.id);
|
|
1260
|
+
}
|
|
1261
|
+
} catch (e) {}
|
|
1262
|
+
for (const header of headers) {
|
|
1263
|
+
if (matches(header.cwd)) ids.add(header.id);
|
|
1264
|
+
}
|
|
1265
|
+
for (const id of keptIds()) ids.delete(id);
|
|
1266
|
+
return ids;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/** The probe folder's workspace, when the registry has one. */
|
|
1270
|
+
function probeWorkspace(ctx, folderNorm) {
|
|
1271
|
+
return ctx.workspaceRegistry.list().find((w) => {
|
|
1272
|
+
try { return path.resolve(w.path) === folderNorm; } catch (e) { return false; }
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// A count walks the persistence listing, which stats every session directory
|
|
1277
|
+
// the harness holds, so it is rate-limited well below the console's poll.
|
|
1278
|
+
const ORPHAN_RECOUNT_MS = 10_000;
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* Probe conversations in the folder this plugin is not tracking — what the
|
|
1282
|
+
* user sees as "I stopped and deleted everything and the sidebar is still
|
|
1283
|
+
* full". A session with no card is one no console button can reach, and the
|
|
1284
|
+
* shell's own menu offers Archive but not Delete.
|
|
1285
|
+
*/
|
|
1286
|
+
async function orphans(ctx) {
|
|
1287
|
+
const folderNorm = path.resolve(state.config.folder);
|
|
1288
|
+
const known = new Set();
|
|
1289
|
+
for (const attempt of state.attempts) {
|
|
1290
|
+
if (attempt.sessionId) known.add(attempt.sessionId);
|
|
1291
|
+
}
|
|
1292
|
+
let headers = [];
|
|
1293
|
+
try { headers = await ctx.sessionPersistence.list(); } catch (e) {}
|
|
1294
|
+
const workspace = probeWorkspace(ctx, folderNorm);
|
|
1295
|
+
const ids = [...probeSessionIds(ctx, folderNorm, headers, workspace)]
|
|
1296
|
+
.filter((id) => !known.has(id));
|
|
1297
|
+
return { ids, headers, workspace };
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
async function countOrphans(ctx, force) {
|
|
1301
|
+
if (!force && Date.now() - state.orphans.at < ORPHAN_RECOUNT_MS) return state.orphans;
|
|
1302
|
+
const { ids } = await orphans(ctx);
|
|
1303
|
+
let live = 0;
|
|
1304
|
+
for (const id of ids) {
|
|
1305
|
+
if (ctx.sessions?.get(id) !== undefined) live += 1;
|
|
1306
|
+
}
|
|
1307
|
+
state.orphans = { live, cold: ids.length - live, at: Date.now() };
|
|
1308
|
+
return state.orphans;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/**
|
|
1312
|
+
* Cancel the turn of anything still live so it stops drawing tokens, drop the
|
|
1313
|
+
* workspace slot, unlink the log. A live orphan's agent belongs to the
|
|
1314
|
+
* harness, not to this plugin — the handle that could dispose it died with
|
|
1315
|
+
* the plugin instance that made it — so the session object survives in the
|
|
1316
|
+
* store until the app restarts. Cancelling is the part that matters.
|
|
1317
|
+
*/
|
|
1318
|
+
async function reapOrphans(ctx) {
|
|
1319
|
+
if (state.running) throw new Error('运行中不能清理 / cannot sweep while running');
|
|
1320
|
+
const { ids, headers, workspace } = await orphans(ctx);
|
|
1321
|
+
let failure = null;
|
|
1322
|
+
for (const id of ids) {
|
|
1323
|
+
try { ctx.agents?.get(id)?.cancel({ kind: 'user' }, { keepInbox: false }); } catch (e) {}
|
|
1324
|
+
try { await workspace?.detachSession(id); } catch (e) {}
|
|
1325
|
+
try {
|
|
1326
|
+
await removeSessionLog(ctx, id, headers, state.config.folder);
|
|
1327
|
+
} catch (error) {
|
|
1328
|
+
failure = failure ?? (error instanceof Error ? error.message : String(error));
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
state.note = 'reaped';
|
|
1332
|
+
state.reaped = ids.length;
|
|
1333
|
+
await countOrphans(ctx, true);
|
|
1334
|
+
if (failure !== null) throw new Error(failure);
|
|
1335
|
+
return publicState();
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/**
|
|
1339
|
+
* Delete every probe conversation on disk — including ones already
|
|
1340
|
+
* dropped from the in-memory list by a previous clear — and reset
|
|
1341
|
+
* numbering so the next run starts at probe 1.
|
|
1342
|
+
*/
|
|
1343
|
+
async function deleteAll(ctx) {
|
|
1344
|
+
if (state.running) throw new Error('运行中不能删除 / cannot delete while running');
|
|
1345
|
+
// Deleting a session log out from under a live turn corrupts it and leaves
|
|
1346
|
+
// an agent writing to a directory that is gone. Only the cohort matters: a
|
|
1347
|
+
// kept probe is live on purpose and is not going to be deleted, so it must
|
|
1348
|
+
// not block the delete forever.
|
|
1349
|
+
if (state.attempts.filter(sweepable).some(isLive)) {
|
|
1350
|
+
throw new Error('仍有探测在进行中,请先强制停止 / probes are still live — force stop first');
|
|
1351
|
+
}
|
|
1352
|
+
const folderNorm = path.resolve(state.config.folder);
|
|
1353
|
+
for (const attempt of state.attempts.filter(sweepable)) {
|
|
1354
|
+
clearFade(attempt);
|
|
1355
|
+
clearWatchdog(attempt);
|
|
1356
|
+
try { await attempt.handle?.dispose(); } catch (e) {}
|
|
1357
|
+
attempt.handle = null;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
const workspace = probeWorkspace(ctx, folderNorm);
|
|
1361
|
+
let headers = [];
|
|
1362
|
+
try { headers = await ctx.sessionPersistence.list(); } catch (e) {}
|
|
1363
|
+
const ids = probeSessionIds(ctx, folderNorm, headers, workspace);
|
|
1364
|
+
// One session failing to unlink must not strand the rest: keep going and
|
|
1365
|
+
// report afterwards, once the list has already been reset.
|
|
1366
|
+
let failure = null;
|
|
1367
|
+
for (const sessionId of ids) {
|
|
1368
|
+
// Anything still live here is an orphan from an earlier plugin instance
|
|
1369
|
+
// — this run's own probes were required to be finished before we got
|
|
1370
|
+
// this far. Cancel it so the log stops being written to before it goes.
|
|
1371
|
+
try { ctx.agents?.get(sessionId)?.cancel({ kind: 'user' }, { keepInbox: false }); } catch (e) {}
|
|
1372
|
+
try { await workspace?.detachSession(sessionId); } catch (e) {}
|
|
1373
|
+
try {
|
|
1374
|
+
await removeSessionLog(ctx, sessionId, headers, state.config.folder);
|
|
1375
|
+
} catch (error) {
|
|
1376
|
+
failure = failure ?? (error instanceof Error ? error.message : String(error));
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// Kept catches survive the delete, so they keep their cards too: dropping
|
|
1381
|
+
// them would leave a conversation the console cannot account for.
|
|
1382
|
+
state.attempts = state.attempts.filter((a) => !sweepable(a));
|
|
1383
|
+
if (state.attempts.length === 0) state.sequence = 0;
|
|
1384
|
+
state.launched = 0;
|
|
1385
|
+
state.paused = false;
|
|
1386
|
+
state.note = null;
|
|
1387
|
+
state.lastError = null;
|
|
1388
|
+
state.launchFailures = 0;
|
|
1389
|
+
await countOrphans(ctx, true);
|
|
1390
|
+
if (failure !== null) throw new Error(failure);
|
|
1391
|
+
return publicState();
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/* -------------------------------------------------------------------- http -- */
|
|
1395
|
+
|
|
1396
|
+
// Actions here start conversations and delete session logs, and the route
|
|
1397
|
+
// listens on a local port that any page in the browser can reach. Without a
|
|
1398
|
+
// check, a page the user happens to be visiting could POST `delete-all` as a
|
|
1399
|
+
// CORS "simple request": it could not read the reply, but the deletion would
|
|
1400
|
+
// still happen. Two things prevent that. Requiring a JSON content type takes
|
|
1401
|
+
// the request out of the simple set, so the browser must preflight it — and
|
|
1402
|
+
// no CORS headers are ever sent, so the preflight fails. Rejecting a
|
|
1403
|
+
// cross-origin `Origin` closes the gap for any client that skips preflight.
|
|
1404
|
+
const MAX_BODY_BYTES = 256 * 1024;
|
|
1405
|
+
|
|
1406
|
+
function sameOrigin(request) {
|
|
1407
|
+
const origin = request.headers.origin;
|
|
1408
|
+
// Same-origin fetches send no Origin header at all.
|
|
1409
|
+
if (origin === undefined || origin === 'null') return true;
|
|
1410
|
+
let host;
|
|
1411
|
+
try { ({ host } = new URL(origin)); } catch (e) { return false; }
|
|
1412
|
+
return host === request.headers.host;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
function isJsonBody(request) {
|
|
1416
|
+
const type = request.headers['content-type'];
|
|
1417
|
+
if (typeof type !== 'string') return false;
|
|
1418
|
+
return type.split(';')[0].trim().toLowerCase() === 'application/json';
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
function requestJson(request) {
|
|
1422
|
+
return new Promise((resolve, reject) => {
|
|
1423
|
+
const decoder = new TextDecoder();
|
|
1424
|
+
let text = '';
|
|
1425
|
+
let bytes = 0;
|
|
1426
|
+
request.on('data', (chunk) => {
|
|
1427
|
+
bytes += chunk.length;
|
|
1428
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
1429
|
+
reject(new TypeError('请求体过大 / request body too large'));
|
|
1430
|
+
request.destroy();
|
|
1431
|
+
return;
|
|
1432
|
+
}
|
|
1433
|
+
text += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
|
|
1434
|
+
});
|
|
1435
|
+
request.on('end', () => {
|
|
1436
|
+
try {
|
|
1437
|
+
text += decoder.decode();
|
|
1438
|
+
resolve(text === '' ? {} : JSON.parse(text));
|
|
1439
|
+
} catch (error) {
|
|
1440
|
+
reject(error);
|
|
1441
|
+
}
|
|
1442
|
+
});
|
|
1443
|
+
request.on('error', reject);
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
function respondJson(response, status, value) {
|
|
1448
|
+
response.writeHead(status, {
|
|
1449
|
+
'content-type': 'application/json; charset=utf-8',
|
|
1450
|
+
'cache-control': 'no-store',
|
|
1451
|
+
});
|
|
1452
|
+
response.end(JSON.stringify(value));
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
async function handleRoute(ctx, request, response) {
|
|
1456
|
+
try {
|
|
1457
|
+
if (!sameOrigin(request)) {
|
|
1458
|
+
respondJson(response, 403, { error: '跨源请求被拒绝 / cross-origin request refused' });
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
if (request.method === 'GET') {
|
|
1462
|
+
// Refreshes for the next poll rather than in-band: the scan stats every
|
|
1463
|
+
// session directory the harness has, and a status read must not wait on
|
|
1464
|
+
// it or fail with it.
|
|
1465
|
+
countOrphans(ctx).catch(() => {});
|
|
1466
|
+
respondJson(response, 200, publicState());
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
if (request.method === 'POST') {
|
|
1470
|
+
if (!isJsonBody(request)) {
|
|
1471
|
+
respondJson(response, 415, {
|
|
1472
|
+
error: 'content-type 必须是 application/json / content-type must be application/json',
|
|
1473
|
+
});
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
const body = await requestJson(request);
|
|
1477
|
+
switch (body.action) {
|
|
1478
|
+
case 'start':
|
|
1479
|
+
respondJson(response, 200, await start(ctx, body.config));
|
|
1480
|
+
return;
|
|
1481
|
+
case 'pause':
|
|
1482
|
+
respondJson(response, 200, pause(ctx));
|
|
1483
|
+
return;
|
|
1484
|
+
case 'resume':
|
|
1485
|
+
respondJson(response, 200, resume(ctx));
|
|
1486
|
+
return;
|
|
1487
|
+
case 'force-stop':
|
|
1488
|
+
respondJson(response, 200, forceStop(ctx));
|
|
1489
|
+
return;
|
|
1490
|
+
case 'clear':
|
|
1491
|
+
respondJson(response, 200, await clearHistory(ctx));
|
|
1492
|
+
return;
|
|
1493
|
+
case 'delete-all':
|
|
1494
|
+
respondJson(response, 200, await deleteAll(ctx));
|
|
1495
|
+
return;
|
|
1496
|
+
case 'pin':
|
|
1497
|
+
respondJson(response, 200, pinAttempt(body.id));
|
|
1498
|
+
return;
|
|
1499
|
+
case 'hold':
|
|
1500
|
+
respondJson(response, 200, holdAttempt(body.id));
|
|
1501
|
+
return;
|
|
1502
|
+
case 'release':
|
|
1503
|
+
respondJson(response, 200, releaseAttempt(ctx, body.id));
|
|
1504
|
+
return;
|
|
1505
|
+
case 'protect':
|
|
1506
|
+
respondJson(response, 200, await protectAttempt(body.id));
|
|
1507
|
+
return;
|
|
1508
|
+
case 'unprotect':
|
|
1509
|
+
respondJson(response, 200, await unprotectAttempt(body.id));
|
|
1510
|
+
return;
|
|
1511
|
+
case 'rename':
|
|
1512
|
+
respondJson(response, 200, await renameAttempt(ctx, body.id, body.title));
|
|
1513
|
+
return;
|
|
1514
|
+
case 'self-check':
|
|
1515
|
+
respondJson(response, 200, selfCheck(body.config));
|
|
1516
|
+
return;
|
|
1517
|
+
case 'reap':
|
|
1518
|
+
respondJson(response, 200, await reapOrphans(ctx));
|
|
1519
|
+
return;
|
|
1520
|
+
case 'mute-notifications':
|
|
1521
|
+
respondJson(response, 200, await muteNotifications(ctx));
|
|
1522
|
+
return;
|
|
1523
|
+
default:
|
|
1524
|
+
throw new TypeError('未知 action / unknown action');
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
response.writeHead(405);
|
|
1528
|
+
response.end();
|
|
1529
|
+
} catch (error) {
|
|
1530
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1531
|
+
// Bad input is the caller's fault (400); anything else is a state
|
|
1532
|
+
// conflict (409). A malformed body throws SyntaxError out of JSON.parse,
|
|
1533
|
+
// which used to be reported as a conflict.
|
|
1534
|
+
const badRequest = error instanceof TypeError || error instanceof SyntaxError;
|
|
1535
|
+
respondJson(response, badRequest ? 400 : 409, { error: message });
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
/**
|
|
1540
|
+
* Probe agents are owned by the harness's agent service, not by this plugin's
|
|
1541
|
+
* fiber, so they outlive a reload or an upgrade — which is how a folder ends
|
|
1542
|
+
* up full of conversations no console can reach. Tearing the cohort down on
|
|
1543
|
+
* unload keeps that from happening in the first place; anything outside it
|
|
1544
|
+
* is deliberately left running.
|
|
1545
|
+
*/
|
|
1546
|
+
function releaseOnUnload(ctx) {
|
|
1547
|
+
for (const attempt of state.attempts.filter(sweepable)) {
|
|
1548
|
+
clearFade(attempt);
|
|
1549
|
+
clearWatchdog(attempt);
|
|
1550
|
+
const handle = attempt.handle;
|
|
1551
|
+
attempt.handle = null;
|
|
1552
|
+
attempt.closed = true;
|
|
1553
|
+
attempt.status = 'stopped';
|
|
1554
|
+
attempt.endedAt = attempt.endedAt ?? Date.now();
|
|
1555
|
+
if (handle === null) continue;
|
|
1556
|
+
try { handle.agent.cancel({ kind: 'user' }, { keepInbox: false }); } catch (e) {}
|
|
1557
|
+
release(handle);
|
|
1558
|
+
}
|
|
1559
|
+
state.running = false;
|
|
1560
|
+
state.paused = false;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
function apply(ctx) {
|
|
1564
|
+
host = ctx;
|
|
1565
|
+
loadPromises(state.config.folder);
|
|
1566
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1567
|
+
kind: 'exact',
|
|
1568
|
+
path: ROLLOUT_SCOUT_PATH,
|
|
1569
|
+
handler: (request, response) => handleRoute(ctx, request, response),
|
|
1570
|
+
}), 'rollout-scout: HTTP route');
|
|
1571
|
+
ctx.effect(() => () => releaseOnUnload(ctx), 'rollout-scout: release probes');
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// `classify` and `chineseShare` are exported so the classifier can be tested
|
|
1575
|
+
// directly against recorded chains-of-thought without spending a real probe;
|
|
1576
|
+
// `sanitizeConfig` so the folder guard can be tested without touching disk;
|
|
1577
|
+
// `wantsDiscard` so discard rules (TPS, TTFT, etc.) can be tested directly;
|
|
1578
|
+
// `selfCheck` so the screenshot script reports real numbers rather than
|
|
1579
|
+
// numbers someone typed into a mock.
|
|
1580
|
+
export {
|
|
1581
|
+
ROLLOUT_SCOUT_PATH, apply, chineseShare, classify, inject, name,
|
|
1582
|
+
sanitizeConfig, selfCheck, wantsDiscard,
|
|
1583
|
+
};
|