dsh-connect-workbuddy 5.13.0 → 5.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +161 -0
- package/README.en.md +14 -7
- package/README.md +16 -7
- package/lib/bin.js +1 -1
- package/lib/client.js +68 -6
- package/lib/{host-heartbeat-DLVGPY4k.js → host-heartbeat-DMC9icX8.js} +294 -22
- package/lib/index.d.ts +91 -16
- package/lib/index.js +165 -56
- package/package.json +1 -1
|
@@ -1039,6 +1039,47 @@ function imageDefaultFor(info) {
|
|
|
1039
1039
|
if (info.supportsImages === false) return false;
|
|
1040
1040
|
return NATIVE_MODALITY_BY_MODEL_ID[info.id] === "multimodal";
|
|
1041
1041
|
}
|
|
1042
|
+
/**
|
|
1043
|
+
* Delete the oldest files in `dir`, keeping the newest `keep`.
|
|
1044
|
+
*
|
|
1045
|
+
* Ordering is by mtime, not filename: the image name embeds an ISO timestamp
|
|
1046
|
+
* and the video name embeds one plus a URL tail, so neither sorts reliably as a
|
|
1047
|
+
* string once more than one generator writes into the directory.
|
|
1048
|
+
*
|
|
1049
|
+
* @returns how many files were removed (for tests and for callers that want to
|
|
1050
|
+
* log a real number); `0` when the directory is absent, unreadable, small
|
|
1051
|
+
* enough, or when every removal failed.
|
|
1052
|
+
*/
|
|
1053
|
+
async function pruneTempDir(dir, keep = 20, extensions = [".png", ".mp4"]) {
|
|
1054
|
+
let names;
|
|
1055
|
+
try {
|
|
1056
|
+
names = (await readdir(dir)).filter((name) => extensions.some((ext) => name.endsWith(ext)));
|
|
1057
|
+
} catch {
|
|
1058
|
+
return 0;
|
|
1059
|
+
}
|
|
1060
|
+
if (names.length <= keep) return 0;
|
|
1061
|
+
const dated = await Promise.all(names.map(async (name) => {
|
|
1062
|
+
const path = join(dir, name);
|
|
1063
|
+
try {
|
|
1064
|
+
return {
|
|
1065
|
+
path,
|
|
1066
|
+
mtimeMs: (await stat(path)).mtimeMs
|
|
1067
|
+
};
|
|
1068
|
+
} catch {
|
|
1069
|
+
return {
|
|
1070
|
+
path,
|
|
1071
|
+
mtimeMs: 0
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
}));
|
|
1075
|
+
dated.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
1076
|
+
let removed = 0;
|
|
1077
|
+
for (const entry of dated.slice(Math.max(0, keep))) try {
|
|
1078
|
+
await rm(entry.path, { force: true });
|
|
1079
|
+
removed += 1;
|
|
1080
|
+
} catch {}
|
|
1081
|
+
return removed;
|
|
1082
|
+
}
|
|
1042
1083
|
/** Everything the app treats as a queue condition (`isQueueError`). */
|
|
1043
1084
|
const WORKBUDDY_QUEUE_CODES = [
|
|
1044
1085
|
6020,
|
|
@@ -3503,6 +3544,104 @@ function scansOpening(role) {
|
|
|
3503
3544
|
return name === "system" || name === "assistant";
|
|
3504
3545
|
}
|
|
3505
3546
|
/**
|
|
3547
|
+
* The gateway's current content fingerprints, and same-meaning rewrites for
|
|
3548
|
+
* them (issue #163).
|
|
3549
|
+
*
|
|
3550
|
+
* WHY REWRITE INSTEAD OF ZERO-WIDTH. On 2026-10-29 two independent probes
|
|
3551
|
+
* measured that the CN gateway normalizes invisible characters before matching:
|
|
3552
|
+
* the DSH identity with U+200B at every word boundary still answers 11128.
|
|
3553
|
+
* `breakIdentityClauses` and both perturbation rungs therefore cannot help this
|
|
3554
|
+
* family. A real wording change is what the same probes measured as 200.
|
|
3555
|
+
*
|
|
3556
|
+
* WHY SENTENCE-LEVEL, NOT ONE WHOLE-PARAGRAPH LITERAL. The third fingerprint is
|
|
3557
|
+
* an eight-sentence, roughly 800-character @-path paragraph. Two reporters
|
|
3558
|
+
* measured that each sentence alone answers 200 and the whole paragraph answers
|
|
3559
|
+
* 400 — it is a COMBINATION, not a single listed sentence. Replacing the
|
|
3560
|
+
* paragraph as one literal would stop matching the moment the host changes one
|
|
3561
|
+
* word; replacing every sentence breaks the combination and keeps working when
|
|
3562
|
+
* only some of the text moves. The rows below are the table proposed in issue
|
|
3563
|
+
* #163 (yicneggima-glitch, 18:56; QIYU-PLUS, 18:57), with the identity and Web
|
|
3564
|
+
* GUI rows both probes measured.
|
|
3565
|
+
*
|
|
3566
|
+
* `from` strings are matched ANYWHERE, not just at an opening, because the
|
|
3567
|
+
* measured rule is position-independent. Only a hit is replaced; every other
|
|
3568
|
+
* byte is left alone, so an ordinary conversation is unchanged.
|
|
3569
|
+
*
|
|
3570
|
+
* WHICH HOST PACKAGE TO WATCH WHEN A ROW STOPS MATCHING (issue #164 traced the
|
|
3571
|
+
* three paragraphs to their owners in the DSH install): the identity sentence
|
|
3572
|
+
* comes from `@deepseek-ai/dsh-system-prompt`, the @-path paragraph from
|
|
3573
|
+
* `@deepseek-ai/dsh-file-reference`, and the Web GUI paragraph from
|
|
3574
|
+
* `@deepseek-ai/dsh-harness-dev-context`. #164 also bisected the MINIMUM
|
|
3575
|
+
* triggering span: the @-path paragraph already fails at its first two sentences
|
|
3576
|
+
* (112 chars) and the Web GUI paragraph at its first two (217 chars), each
|
|
3577
|
+
* sentence alone answering 200 — so a row must clear the truncated shape too,
|
|
3578
|
+
* which is exactly what sentence-level rows do. Pinned by
|
|
3579
|
+
* `tests/issue163-sentence-rewrite.spec.ts`.
|
|
3580
|
+
*/
|
|
3581
|
+
const CHANNEL_GATE_PHRASES = [
|
|
3582
|
+
["You are an AI agent powered by DeepSeek Harness", "You are an AI agent running inside DeepSeek Harness"],
|
|
3583
|
+
["You are a coding agent powered by the ", "You are a coding agent running on the "],
|
|
3584
|
+
["You are interacting with the user through the DeepSeek Harness Web GUI", "You are working with the user inside the DeepSeek Harness Web GUI"],
|
|
3585
|
+
["Agent Teams is available in this session", "Agent Teams is enabled for this session"],
|
|
3586
|
+
["Tokens prefixed with @ are paths the user explicitly referenced.", "Paths that begin with @ are the ones the user explicitly referenced."],
|
|
3587
|
+
["Relative paths resolve from the workspace root;", "Relative paths are resolved against the workspace root;"],
|
|
3588
|
+
["absolute paths identify files or directories on the host.", "absolute paths point at files or directories on the host."],
|
|
3589
|
+
["A trailing slash marks a directory:", "A trailing slash indicates a directory:"],
|
|
3590
|
+
["list it when its contents matter.", "list it whenever its contents matter."],
|
|
3591
|
+
["Anything else is a file:", "Everything else is a file:"],
|
|
3592
|
+
["use the read tool when its contents are needed,", "use the read tool whenever you need its contents,"],
|
|
3593
|
+
["and do not claim to have inspected it before reading.", "and never claim to have inspected it before reading it."],
|
|
3594
|
+
["@\"...\" quotes a path containing spaces.", "A path containing spaces is quoted with @\"...\"."],
|
|
3595
|
+
["without naming another target, they mean this GUI.", "and names no other target, the interface described here is meant."],
|
|
3596
|
+
["they mean this GUI.", "the interface described here is meant."]
|
|
3597
|
+
];
|
|
3598
|
+
/**
|
|
3599
|
+
* Rewrite the measured gate phrases in one text part.
|
|
3600
|
+
*
|
|
3601
|
+
* Exported for the tests and for callers that build a body themselves. It is
|
|
3602
|
+
* idempotent: every `to` string is different from its `from` string and no
|
|
3603
|
+
* rewrite recreates another `from` string, so a second pass is a no-op.
|
|
3604
|
+
*/
|
|
3605
|
+
function rewriteChannelFingerprintPhrases(text) {
|
|
3606
|
+
let out = text;
|
|
3607
|
+
for (const [from, to] of CHANNEL_GATE_PHRASES) if (out.includes(from)) out = out.replaceAll(from, to);
|
|
3608
|
+
return out;
|
|
3609
|
+
}
|
|
3610
|
+
/**
|
|
3611
|
+
* Apply {@link rewriteChannelFingerprintPhrases} to every scanned text part.
|
|
3612
|
+
*
|
|
3613
|
+
* `system` and `assistant` only (the roles the gate reads). `user` and `tool`
|
|
3614
|
+
* are deliberately untouched: QIYU-PLUS re-measured both on 2026-10-29 and
|
|
3615
|
+
* both answer 200 with the DSH identity at an opening, and rewriting a user's
|
|
3616
|
+
* own words would change the conversation for no gate.
|
|
3617
|
+
*/
|
|
3618
|
+
function rewriteChannelFingerprintPhrasesInBody(obj) {
|
|
3619
|
+
const messages = obj["messages"];
|
|
3620
|
+
if (!Array.isArray(messages)) return;
|
|
3621
|
+
for (const value of messages) {
|
|
3622
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
3623
|
+
const message = value;
|
|
3624
|
+
if (!scansOpening(message["role"])) continue;
|
|
3625
|
+
const content = message["content"];
|
|
3626
|
+
if (typeof content === "string") {
|
|
3627
|
+
const next = rewriteChannelFingerprintPhrases(content);
|
|
3628
|
+
if (next !== content) message["content"] = next;
|
|
3629
|
+
continue;
|
|
3630
|
+
}
|
|
3631
|
+
if (!Array.isArray(content)) continue;
|
|
3632
|
+
for (const part of content) {
|
|
3633
|
+
if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
|
|
3634
|
+
const record = part;
|
|
3635
|
+
const type = record["type"];
|
|
3636
|
+
if (typeof type !== "string" || type.trim().toLowerCase() !== "text") continue;
|
|
3637
|
+
const text = record["text"];
|
|
3638
|
+
if (typeof text !== "string") continue;
|
|
3639
|
+
const next = rewriteChannelFingerprintPhrases(text);
|
|
3640
|
+
if (next !== text) record["text"] = next;
|
|
3641
|
+
}
|
|
3642
|
+
}
|
|
3643
|
+
}
|
|
3644
|
+
/**
|
|
3506
3645
|
* Move every scanned message (`system` and `assistant`) off the content
|
|
3507
3646
|
* fingerprint above by prepending the placeholder head.
|
|
3508
3647
|
*
|
|
@@ -4153,6 +4292,7 @@ function prepareChatBody(source, options) {
|
|
|
4153
4292
|
}
|
|
4154
4293
|
ensureSystemHead(obj);
|
|
4155
4294
|
if (options?.keepPromptLast === true && Array.isArray(obj["messages"])) keepPromptLast(obj["messages"], injectedPrefixPattern(options.extraInjectedPrefixes));
|
|
4295
|
+
rewriteChannelFingerprintPhrasesInBody(obj);
|
|
4156
4296
|
neutralizeChannelFingerprints(obj);
|
|
4157
4297
|
normalizeToolChoice(obj);
|
|
4158
4298
|
dropRefusedReasoning(obj);
|
|
@@ -5158,6 +5298,7 @@ var WorkBuddyUpstreamClient = class {
|
|
|
5158
5298
|
const safeUrl = url.replace(/[^A-Za-z0-9._-]+/g, "_").slice(-32);
|
|
5159
5299
|
const path = join(dir, `video-${stamp}-${safeUrl}.mp4`);
|
|
5160
5300
|
await writeFile(path, buffer);
|
|
5301
|
+
pruneTempDir(dir, 20, [".mp4"]);
|
|
5161
5302
|
return path;
|
|
5162
5303
|
}
|
|
5163
5304
|
/** Query today's check-in status without changing account state. */
|
|
@@ -5544,8 +5685,38 @@ function mergeCredentialDisplayNames(previous, next) {
|
|
|
5544
5685
|
const WORKBUDDY_AUTH_FILENAME = ".workbuddy-auth.json";
|
|
5545
5686
|
/** Env variable that overrides the desktop auth-file location. */
|
|
5546
5687
|
const WORKBUDDY_AUTH_FILE_ENV = "WORKBUDDY_AUTH_FILE";
|
|
5547
|
-
/**
|
|
5548
|
-
|
|
5688
|
+
/**
|
|
5689
|
+
* Basenames the live WorkBuddy desktop auth file is known to use.
|
|
5690
|
+
*
|
|
5691
|
+
* ISSUE #174: the constant used to be ONE name, `workbuddy-desktop.info`, and
|
|
5692
|
+
* the 5.7.x Windows app writes `workbuddy-desktop-ai.info` instead — the same
|
|
5693
|
+
* directory, the same payload, a different filename (measured by the reporter:
|
|
5694
|
+
* `%LOCALAPPDATA%\CodeBuddyExtension\Data\Public\auth\` held
|
|
5695
|
+
* `workbuddy-desktop-ai.info` plus timestamped backups of that same name, and no
|
|
5696
|
+
* file carrying the constant's name at all). The consequences were both silent:
|
|
5697
|
+
* the "live file always wins" rule in {@link fileRank} never matched, so
|
|
5698
|
+
* ordering fell back to `lastRefreshAtMs` (correct by luck, not by rule), and
|
|
5699
|
+
* `doctor` reported `desktopAuthFile.present: false` with a hint to set
|
|
5700
|
+
* `WORKBUDDY_AUTH_FILE` — on a machine whose credential had just been read
|
|
5701
|
+
* successfully from the default directory.
|
|
5702
|
+
*
|
|
5703
|
+
* Ordered, not a set: {@link WORKBUDDY_LIVE_FILENAME} stays the one this build
|
|
5704
|
+
* treats as canonical for writing, and the alternatives are probed after it. The
|
|
5705
|
+
* FIRST entry is what {@link defaultDesktopAuthCandidates} reports as the
|
|
5706
|
+
* expected path, so keep it there.
|
|
5707
|
+
*/
|
|
5708
|
+
const WORKBUDDY_LIVE_FILENAMES = ["workbuddy-desktop.info", "workbuddy-desktop-ai.info"];
|
|
5709
|
+
WORKBUDDY_LIVE_FILENAMES[0];
|
|
5710
|
+
/**
|
|
5711
|
+
* Whether one basename is a LIVE desktop auth file (any of the known names).
|
|
5712
|
+
*
|
|
5713
|
+
* Every place that asked "is this the live file?" now asks this, so a new
|
|
5714
|
+
* spelling is added in one list instead of being chased through the reader, the
|
|
5715
|
+
* ranker and `doctor` separately — which is exactly how the three drifted apart.
|
|
5716
|
+
*/
|
|
5717
|
+
function isLiveDesktopAuthFileName(name) {
|
|
5718
|
+
return WORKBUDDY_LIVE_FILENAMES.includes(name);
|
|
5719
|
+
}
|
|
5549
5720
|
/** Prefix of the plugin-owned per-region credential copies (legacy, read-only). */
|
|
5550
5721
|
const WORKBUDDY_OWN_PREFIX = ".workbuddy-auth";
|
|
5551
5722
|
/**
|
|
@@ -5789,9 +5960,17 @@ function defaultDesktopAuthDirs(platform = process.platform, home = homedir(), e
|
|
|
5789
5960
|
function nonEmptyEnv(value) {
|
|
5790
5961
|
return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
|
|
5791
5962
|
}
|
|
5792
|
-
/**
|
|
5963
|
+
/**
|
|
5964
|
+
* The live auth file's platform candidates, in probe order.
|
|
5965
|
+
*
|
|
5966
|
+
* Every known basename for every platform directory: a machine can hold the old
|
|
5967
|
+
* and the new spelling side by side during an app upgrade, and probing only the
|
|
5968
|
+
* canonical one is the defect issue #174 reports.
|
|
5969
|
+
*/
|
|
5793
5970
|
function defaultDesktopAuthCandidates() {
|
|
5794
|
-
|
|
5971
|
+
const candidates = [];
|
|
5972
|
+
for (const dir of defaultDesktopAuthDirs()) for (const name of WORKBUDDY_LIVE_FILENAMES) candidates.push(join(dir, name));
|
|
5973
|
+
return candidates;
|
|
5795
5974
|
}
|
|
5796
5975
|
/** First platform-default candidate; see {@link defaultDesktopAuthCandidates}. */
|
|
5797
5976
|
function defaultDesktopAuthPath() {
|
|
@@ -5939,7 +6118,7 @@ function hasEncryptedCredentialFields(text) {
|
|
|
5939
6118
|
* backups, never the primary ordering.
|
|
5940
6119
|
*/
|
|
5941
6120
|
function fileRank(path) {
|
|
5942
|
-
return authFileName(path)
|
|
6121
|
+
return isLiveDesktopAuthFileName(authFileName(path)) ? 0 : 1;
|
|
5943
6122
|
}
|
|
5944
6123
|
/**
|
|
5945
6124
|
* Whether `candidate` is a better pick than `incumbent` for the same account.
|
|
@@ -6328,7 +6507,7 @@ var WorkBuddyCredentialStore = class {
|
|
|
6328
6507
|
const fromEnv = process.env[WORKBUDDY_AUTH_FILE_ENV];
|
|
6329
6508
|
const explicit = this.desktopPathOverride ?? (fromEnv !== void 0 && fromEnv.trim() !== "" ? fromEnv : void 0);
|
|
6330
6509
|
if (explicit !== void 0) return [explicit];
|
|
6331
|
-
return
|
|
6510
|
+
return (this.authDirs ?? defaultDesktopAuthDirs()).flatMap((dir) => WORKBUDDY_LIVE_FILENAMES.map((name) => join(dir, name)));
|
|
6332
6511
|
}
|
|
6333
6512
|
/** The resolved desktop auth-file path, for diagnostics. */
|
|
6334
6513
|
desktopAuthPath() {
|
|
@@ -6355,10 +6534,20 @@ var WorkBuddyCredentialStore = class {
|
|
|
6355
6534
|
return files;
|
|
6356
6535
|
}
|
|
6357
6536
|
const dirs = this.authDirs ?? defaultDesktopAuthDirs();
|
|
6537
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6358
6538
|
for (const dir of dirs) {
|
|
6359
|
-
const
|
|
6360
|
-
|
|
6361
|
-
|
|
6539
|
+
for (const name of WORKBUDDY_LIVE_FILENAMES) {
|
|
6540
|
+
const live = join(dir, name);
|
|
6541
|
+
if (!seen.has(live)) {
|
|
6542
|
+
seen.add(live);
|
|
6543
|
+
files.push(live);
|
|
6544
|
+
}
|
|
6545
|
+
}
|
|
6546
|
+
for (const name of WORKBUDDY_LIVE_FILENAMES) for (const backup of await this.backupsBeside(join(dir, name))) {
|
|
6547
|
+
if (seen.has(backup)) continue;
|
|
6548
|
+
seen.add(backup);
|
|
6549
|
+
files.push(backup);
|
|
6550
|
+
}
|
|
6362
6551
|
}
|
|
6363
6552
|
return files;
|
|
6364
6553
|
}
|
|
@@ -6866,8 +7055,33 @@ var WorkBuddyCredentialStore = class {
|
|
|
6866
7055
|
*/
|
|
6867
7056
|
async diagnose() {
|
|
6868
7057
|
const candidates = [];
|
|
6869
|
-
|
|
6870
|
-
|
|
7058
|
+
const desktopTried = this.resolveDesktopCandidates();
|
|
7059
|
+
const probed = [];
|
|
7060
|
+
for (const path of await this.candidateFiles()) probed.push({
|
|
7061
|
+
path,
|
|
7062
|
+
probe: await probeAuthFile(path, this.resolveAtRestKey)
|
|
7063
|
+
});
|
|
7064
|
+
/**
|
|
7065
|
+
* Directories that need no `missing` complaint (issue #174).
|
|
7066
|
+
*
|
|
7067
|
+
* A directory is "accounted for" when it either yielded a readable
|
|
7068
|
+
* credential OR produced some other, more specific complaint. The first case
|
|
7069
|
+
* is the healthy machine whose app writes only one of the two known
|
|
7070
|
+
* spellings; the second is a machine holding a broken file — and telling
|
|
7071
|
+
* that user "no auth file at the expected path" would be both false and
|
|
7072
|
+
* less useful than the `invalid` line they already have.
|
|
7073
|
+
*/
|
|
7074
|
+
const accountedDirs = /* @__PURE__ */ new Set();
|
|
7075
|
+
for (const entry of probed) {
|
|
7076
|
+
if ("credential" in entry.probe) {
|
|
7077
|
+
accountedDirs.add(dirname(entry.path));
|
|
7078
|
+
continue;
|
|
7079
|
+
}
|
|
7080
|
+
if (entry.probe.failure.reason !== "missing") accountedDirs.add(dirname(entry.path));
|
|
7081
|
+
}
|
|
7082
|
+
/** Directories whose `missing` complaint has already been made. */
|
|
7083
|
+
const missingReported = /* @__PURE__ */ new Set();
|
|
7084
|
+
for (const { path, probe } of probed) {
|
|
6871
7085
|
if ("credential" in probe) {
|
|
6872
7086
|
if (!this.matchesRegion(probe.credential.domain)) candidates.push({
|
|
6873
7087
|
path,
|
|
@@ -6877,6 +7091,11 @@ var WorkBuddyCredentialStore = class {
|
|
|
6877
7091
|
});
|
|
6878
7092
|
continue;
|
|
6879
7093
|
}
|
|
7094
|
+
if (probe.failure.reason === "missing") {
|
|
7095
|
+
const dir = dirname(path);
|
|
7096
|
+
if (accountedDirs.has(dir) || missingReported.has(dir)) continue;
|
|
7097
|
+
missingReported.add(dir);
|
|
7098
|
+
}
|
|
6880
7099
|
candidates.push({
|
|
6881
7100
|
path,
|
|
6882
7101
|
source: "desktop",
|
|
@@ -6935,7 +7154,7 @@ var WorkBuddyCredentialStore = class {
|
|
|
6935
7154
|
}
|
|
6936
7155
|
} catch {}
|
|
6937
7156
|
return {
|
|
6938
|
-
tried,
|
|
7157
|
+
tried: [...desktopTried, ...this.ownCandidates()],
|
|
6939
7158
|
failures: candidates
|
|
6940
7159
|
};
|
|
6941
7160
|
}
|
|
@@ -7711,7 +7930,7 @@ function describeRosterSeenPayload(raw) {
|
|
|
7711
7930
|
//#endregion
|
|
7712
7931
|
//#region src/version.ts
|
|
7713
7932
|
/** The npm package version this build was produced from. */
|
|
7714
|
-
const WORKBUDDY_CONNECT_VERSION = "5.
|
|
7933
|
+
const WORKBUDDY_CONNECT_VERSION = "5.14.0";
|
|
7715
7934
|
//#endregion
|
|
7716
7935
|
//#region src/host-heartbeat.ts
|
|
7717
7936
|
/**
|
|
@@ -7729,8 +7948,18 @@ const WORKBUDDY_CONNECT_VERSION = "5.13.0";
|
|
|
7729
7948
|
*/
|
|
7730
7949
|
/** Basename of the host heartbeat file inside the Harness home. */
|
|
7731
7950
|
const WORKBUDDY_HOST_HEARTBEAT_FILENAME = ".workbuddy-host-heartbeat.json";
|
|
7732
|
-
/**
|
|
7733
|
-
|
|
7951
|
+
/**
|
|
7952
|
+
* On-disk heartbeat formats this build WRITES and READS.
|
|
7953
|
+
*
|
|
7954
|
+
* 1 → 2 added `hostStartedAt` (issue #174). Version 1 is still READ, on purpose:
|
|
7955
|
+
* rejecting it would turn the first `doctor` after an upgrade into a spurious
|
|
7956
|
+
* "no heartbeat at all", which is a worse diagnosis than the imprecise
|
|
7957
|
+
* comparison version 1 supports. Anything newer than {@link HEARTBEAT_FORMAT_VERSION}
|
|
7958
|
+
* is rejected, because a future field could change the meaning of the ones read
|
|
7959
|
+
* here.
|
|
7960
|
+
*/
|
|
7961
|
+
const HEARTBEAT_FORMAT_VERSION = 2;
|
|
7962
|
+
const READABLE_HEARTBEAT_VERSIONS = [1, 2];
|
|
7734
7963
|
/** Absolute path of the host heartbeat file. */
|
|
7735
7964
|
function workbuddyHostHeartbeatPath() {
|
|
7736
7965
|
return join(resolveDshHome(), WORKBUDDY_HOST_HEARTBEAT_FILENAME);
|
|
@@ -7815,10 +8044,40 @@ function processStartTimeMs(pid) {
|
|
|
7815
8044
|
}
|
|
7816
8045
|
}
|
|
7817
8046
|
/**
|
|
8047
|
+
* How close a recorded start time must be to the live process's start time for
|
|
8048
|
+
* the two to be considered the SAME process.
|
|
8049
|
+
*
|
|
8050
|
+
* Two tolerances, for two different comparisons:
|
|
8051
|
+
*
|
|
8052
|
+
* - {@link HOST_START_REFERENCE_TOLERANCE_MS} when the heartbeat records
|
|
8053
|
+
* `hostStartedAt` — both sides are then the SAME quantity (a process start
|
|
8054
|
+
* time), so the window only has to absorb the coarse resolution of
|
|
8055
|
+
* `ps -o lstart=` (whole seconds) and PowerShell's string round-trip.
|
|
8056
|
+
* - {@link LEGACY_REFERENCE_TOLERANCE_MS} when it does not, and the only
|
|
8057
|
+
* available reference is `registeredAt`. That is a different quantity
|
|
8058
|
+
* (when the plugin mounted, not when the process started), so it is a
|
|
8059
|
+
* lower bound rather than an answer: a host that started more than this
|
|
8060
|
+
* before the plugin mounted would have looked dead. Kept wide on purpose —
|
|
8061
|
+
* a missed reuse is far less harmful than calling a live host stale.
|
|
8062
|
+
*/
|
|
8063
|
+
const HOST_START_REFERENCE_TOLERANCE_MS = 12e4;
|
|
8064
|
+
const LEGACY_REFERENCE_TOLERANCE_MS = 432e5;
|
|
8065
|
+
/**
|
|
7818
8066
|
* Whether the recorded host process still matches the heartbeat's PID.
|
|
7819
8067
|
*
|
|
7820
8068
|
* A PID can be reused after a crash, so the recorded start time is compared
|
|
7821
8069
|
* against the live process: a different start time means a different process.
|
|
8070
|
+
*
|
|
8071
|
+
* ISSUE #174 — the reference used to be `registeredAt`, the moment the plugin
|
|
8072
|
+
* WROTE the heartbeat, and the comparison was `|startedAt − registeredAt| < 60s`.
|
|
8073
|
+
* Those are two different quantities: `registeredAt` is when the host finished
|
|
8074
|
+
* booting and mounted this plugin, which on a real machine is minutes after the
|
|
8075
|
+
* process started. Measured by the reporter on Windows 11: process start
|
|
8076
|
+
* 19:47:20, heartbeat written 19:58:45 — 684,817 ms apart — so `doctor` and
|
|
8077
|
+
* `status` reported `hostBundle: "stale"` for a host that was alive and had
|
|
8078
|
+
* just written the file. The `hostStartedAt` field exists so the comparison has
|
|
8079
|
+
* the right reference; `registeredAt` is only read for heartbeats written by an
|
|
8080
|
+
* older build, where it is the sole (and merely near) signal available.
|
|
7822
8081
|
*/
|
|
7823
8082
|
function isHeartbeatProcessAlive(heartbeat) {
|
|
7824
8083
|
if (!Number.isInteger(heartbeat.pid) || heartbeat.pid <= 0) return false;
|
|
@@ -7829,7 +8088,9 @@ function isHeartbeatProcessAlive(heartbeat) {
|
|
|
7829
8088
|
}
|
|
7830
8089
|
const startedAt = processStartTimeMs(heartbeat.pid);
|
|
7831
8090
|
if (startedAt === void 0) return true;
|
|
7832
|
-
|
|
8091
|
+
const recorded = heartbeat.hostStartedAt;
|
|
8092
|
+
if (recorded !== void 0 && Number.isFinite(recorded)) return Math.abs(startedAt - recorded) < HOST_START_REFERENCE_TOLERANCE_MS;
|
|
8093
|
+
return Math.abs(startedAt - heartbeat.registeredAt) < LEGACY_REFERENCE_TOLERANCE_MS;
|
|
7833
8094
|
}
|
|
7834
8095
|
/** Read the heartbeat; absent or unparsable files report undefined. */
|
|
7835
8096
|
async function readHostHeartbeat() {
|
|
@@ -7837,30 +8098,41 @@ async function readHostHeartbeat() {
|
|
|
7837
8098
|
const parsed = JSON.parse(await readFile(workbuddyHostHeartbeatPath(), "utf8"));
|
|
7838
8099
|
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
7839
8100
|
const document = parsed;
|
|
7840
|
-
if (document["version"]
|
|
8101
|
+
if (!READABLE_HEARTBEAT_VERSIONS.includes(document["version"])) return void 0;
|
|
7841
8102
|
if (document["package"] !== "dsh-connect-workbuddy") return void 0;
|
|
7842
8103
|
const pid = document["pid"];
|
|
7843
8104
|
const registeredAt = document["registeredAt"];
|
|
7844
8105
|
if (typeof pid !== "number" || typeof registeredAt !== "number") return void 0;
|
|
8106
|
+
const hostStartedAt = document["hostStartedAt"];
|
|
7845
8107
|
return {
|
|
7846
|
-
version:
|
|
8108
|
+
version: document["version"],
|
|
7847
8109
|
package: "dsh-connect-workbuddy",
|
|
7848
8110
|
pluginVersion: typeof document["pluginVersion"] === "string" ? document["pluginVersion"] : WORKBUDDY_CONNECT_VERSION,
|
|
7849
8111
|
registeredAt,
|
|
7850
|
-
pid
|
|
8112
|
+
pid,
|
|
8113
|
+
...typeof hostStartedAt === "number" && Number.isFinite(hostStartedAt) ? { hostStartedAt } : {}
|
|
7851
8114
|
};
|
|
7852
8115
|
} catch {
|
|
7853
8116
|
return;
|
|
7854
8117
|
}
|
|
7855
8118
|
}
|
|
7856
|
-
/**
|
|
8119
|
+
/**
|
|
8120
|
+
* Write the heartbeat for the current process.
|
|
8121
|
+
*
|
|
8122
|
+
* `hostStartedAt` is captured HERE, from this process, so the later staleness
|
|
8123
|
+
* check compares like with like (issue #174). Sampled on every write rather
|
|
8124
|
+
* than cached: the value is cheap, and a module-level cache would be wrong the
|
|
8125
|
+
* moment this function is exercised from a child process.
|
|
8126
|
+
*/
|
|
7857
8127
|
async function writeHostHeartbeat() {
|
|
8128
|
+
const hostStartedAt = processStartTimeMs(process.pid);
|
|
7858
8129
|
const heartbeat = {
|
|
7859
8130
|
version: HEARTBEAT_FORMAT_VERSION,
|
|
7860
8131
|
package: "dsh-connect-workbuddy",
|
|
7861
8132
|
pluginVersion: WORKBUDDY_CONNECT_VERSION,
|
|
7862
8133
|
registeredAt: Date.now(),
|
|
7863
|
-
pid: process.pid
|
|
8134
|
+
pid: process.pid,
|
|
8135
|
+
...hostStartedAt === void 0 ? {} : { hostStartedAt }
|
|
7864
8136
|
};
|
|
7865
8137
|
await writeFile(workbuddyHostHeartbeatPath(), `${JSON.stringify(heartbeat, null, 2)}\n`, { mode: 384 });
|
|
7866
8138
|
}
|
|
@@ -7869,4 +8141,4 @@ async function clearHostHeartbeat() {
|
|
|
7869
8141
|
await rm(workbuddyHostHeartbeatPath(), { force: true });
|
|
7870
8142
|
}
|
|
7871
8143
|
//#endregion
|
|
7872
|
-
export { parseCreditMultiplier as $,
|
|
8144
|
+
export { parseCreditMultiplier as $, resolveDshHome as $t, isEncryptedCredentialError as A, pickMediaModel as At, WorkBuddyCredentialRejectedError as B, deriveAtRestKey as Bt, WorkBuddyCredentialStore as C, IMAGE_TAG_TEXT_TO_IMAGE as Ct, defaultDesktopAuthDirs as D, imageMimeForPath as Dt, defaultDesktopAuthCandidates as E, imageDataUrl as Et, workbuddyAccountId as F, NATIVE_MODALITY_BY_MODEL_ID as Ft, forceNeutralHead as G, isEncryptedFieldWrapper as Gt, channelGateMemberOf as H, fetchAtRestKeyPayload as Ht, workbuddyOwnAuthPath as I, imageDefaultFor as It, isCredentialRejectedError as J, macosNestedAppBundles as Jt, isContextTooLongBody as K, isWorkbuddyBundle as Kt, CONTEXT_OVERFLOW_MARKER as L, nativeModalityOf as Lt, legacyWorkbuddyOwnAuthPath as M, parseQueueAnswer as Mt, parseWorkBuddyAuth as N, queueNoticeText as Nt, defaultDesktopAuthPath as O, isBareBase64Image as Ot, preferredAccountName as P, pruneTempDir as Pt, openingEvidenceOfBody as Q, hostPeersInfo as Qt, CREDENTIAL_REJECTED_CODE as R, WORKBUDDY_APP_EXECUTABLE_ENV as Rt, WORKBUDDY_AUTH_FILE_ENV as S, IMAGE_TAG_IMAGE_TO_IMAGE as St, authFileName as T, WorkBuddyImageError as Tt, classifyUpstreamError as U, findWorkbuddyAppExecutable as Ut, WorkBuddyUpstreamClient as V, deriveAtRestKeyId as Vt, declaredTools as W, findWorkbuddyAppExecutableWithSource as Wt, knownRegionOf as X, readAtRestKey as Xt, isFirstMessageGateBody as Y, openEncryptedField as Yt, modelIdOfBody as Z, workbuddyAppExecutableCandidates as Zt, deriveCatalog as _, WorkBuddyVideoError as _t, readHostHeartbeat as a, requestEvidenceOf as at, ENCRYPTED_CREDENTIAL_CODE as b, resolveVideoParams as bt, WORKBUDDY_CONNECT_VERSION as c, REFUSES_OFF_MODEL_IDS as ct, rosterSeenPath as d, offDefaultFor as dt, withFileLock as en, parseReasoning as et, piAiRuntimeInfo as f, offRefused as ft, applyContextBudgets as g, VIDEO_TAG_TEXT_TO_VIDEO as gt, WorkBuddyCatalog as h, VIDEO_TAG_IMAGE_TO_VIDEO as ht, processStartTimeMs as i, regionOf as it, isVaultAccountId as j, sniffImageMime as jt, hasEncryptedCredentialFields as k, parseImageDataUrl as kt, createFileRosterSeenStore as l, defaultOffModelIds as lt, FALLBACK_WORKBUDDY_MODELS_GLOBAL as m, withEffectiveOff as mt, clearHostHeartbeat as n, perturbChannelFingerprints as nt, workbuddyHostHeartbeatPath as o, rewriteSessionTitleModel as ot, FALLBACK_WORKBUDDY_MODELS as p, recordRefusal as pt, isConversationIdGateBody as q, macosBundleExecutable as qt, isHeartbeatProcessAlive as r, prepareChatBody as rt, writeHostHeartbeat as s, attachBusyLevels as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, writeFileAtomic as tn, parseUpstreamModel as tt, describeRosterSeenPayload as u, lastRefusalFor as ut, fallbackModelsFor as v, parseSupportedSecondsRange as vt, WorkBuddyEncryptedCredentialError as w, MAX_INPUT_IMAGE_BYTES as wt, WORKBUDDY_AUTH_FILENAME as x, DEFAULT_IMAGE_SIZE as xt, withoutRouters as y, pickVideoModel as yt, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as z, clearAtRestKeyCache as zt };
|
package/lib/index.d.ts
CHANGED
|
@@ -208,7 +208,13 @@ declare function legacyWorkbuddyOwnAuthPath(): string;
|
|
|
208
208
|
* testable on any host without touching a real machine.
|
|
209
209
|
*/
|
|
210
210
|
declare function defaultDesktopAuthDirs(platform?: NodeJS.Platform, home?: string, env?: NodeJS.ProcessEnv): string[];
|
|
211
|
-
/**
|
|
211
|
+
/**
|
|
212
|
+
* The live auth file's platform candidates, in probe order.
|
|
213
|
+
*
|
|
214
|
+
* Every known basename for every platform directory: a machine can hold the old
|
|
215
|
+
* and the new spelling side by side during an app upgrade, and probing only the
|
|
216
|
+
* canonical one is the defect issue #174 reports.
|
|
217
|
+
*/
|
|
212
218
|
declare function defaultDesktopAuthCandidates(): string[];
|
|
213
219
|
/** First platform-default candidate; see {@link defaultDesktopAuthCandidates}. */
|
|
214
220
|
declare function defaultDesktopAuthPath(): string | undefined;
|
|
@@ -2334,12 +2340,21 @@ interface WorkBuddyAdapterOptions {
|
|
|
2334
2340
|
*/
|
|
2335
2341
|
piAiGeneration?: PiAiGeneration;
|
|
2336
2342
|
/**
|
|
2337
|
-
* Drop foreign thinking blocks pi-ai would publish as prose
|
|
2343
|
+
* Drop SAME-ROUTE foreign thinking blocks pi-ai would publish as prose
|
|
2344
|
+
* (issue #147).
|
|
2345
|
+
*
|
|
2346
|
+
* CROSS-PROVIDER turns are dropped unconditionally: after a provider switch
|
|
2347
|
+
* the host rebuilds the other provider's assistant turns as `dsh-foreign`,
|
|
2348
|
+
* their unsigned thinking blocks can never be replayed, and pi-ai would
|
|
2349
|
+
* otherwise flatten them into assistant prose that the WorkBuddy gateway
|
|
2350
|
+
* refuses with 11128 (issue #163's measured scene). This switch only widens
|
|
2351
|
+
* the repair to a model switch INSIDE one of this bundle's own routes.
|
|
2338
2352
|
*
|
|
2339
|
-
* OFF by default, and read LIVE (a getter, not a
|
|
2340
|
-
* effect on the next request without reloading
|
|
2341
|
-
* mid-session is the whole point — the user sees
|
|
2342
|
-
* flips the switch, and the next turn is answered
|
|
2353
|
+
* OFF by default for that same-route leg, and read LIVE (a getter, not a
|
|
2354
|
+
* boolean) so the switch takes effect on the next request without reloading
|
|
2355
|
+
* the plugin: turning it on mid-session is the whole point — the user sees
|
|
2356
|
+
* the working-notes turn, flips the switch, and the next turn is answered
|
|
2357
|
+
* normally.
|
|
2343
2358
|
*/
|
|
2344
2359
|
dropForeignThinking?: () => boolean;
|
|
2345
2360
|
/**
|
|
@@ -2731,17 +2746,31 @@ declare function resolveCredentialRecovery(options: {
|
|
|
2731
2746
|
*/
|
|
2732
2747
|
/** Basename of the host heartbeat file inside the Harness home. */
|
|
2733
2748
|
declare const WORKBUDDY_HOST_HEARTBEAT_FILENAME = ".workbuddy-host-heartbeat.json";
|
|
2734
|
-
|
|
2735
|
-
declare const HEARTBEAT_FORMAT_VERSION = 1;
|
|
2749
|
+
declare const READABLE_HEARTBEAT_VERSIONS: readonly number[];
|
|
2736
2750
|
/** On-disk shape of the heartbeat. */
|
|
2737
2751
|
interface WorkBuddyHostHeartbeat {
|
|
2738
|
-
|
|
2752
|
+
/** The format actually on disk; this build writes {@link HEARTBEAT_FORMAT_VERSION}. */
|
|
2753
|
+
version: (typeof READABLE_HEARTBEAT_VERSIONS)[number];
|
|
2739
2754
|
package: 'dsh-connect-workbuddy';
|
|
2740
2755
|
pluginVersion: string;
|
|
2741
2756
|
/** Epoch milliseconds when the host registered the provider. */
|
|
2742
2757
|
registeredAt: number;
|
|
2743
2758
|
/** Host process PID, to distinguish a stale heartbeat after a crash. */
|
|
2744
2759
|
pid: number;
|
|
2760
|
+
/**
|
|
2761
|
+
* Epoch milliseconds when THIS process started (issue #174).
|
|
2762
|
+
*
|
|
2763
|
+
* The staleness check needs to recognise a REUSED PID: `process.kill(pid, 0)`
|
|
2764
|
+
* only says "some process has this number", so after a crash the heartbeat
|
|
2765
|
+
* can describe a process that died while the number now belongs to something
|
|
2766
|
+
* else. The way to tell is to compare the live process's start time against
|
|
2767
|
+
* the one recorded HERE.
|
|
2768
|
+
*
|
|
2769
|
+
* Absent on heartbeats written by older builds (`version: 1`): the reader
|
|
2770
|
+
* then falls back to {@link WorkBuddyHostHeartbeat.registeredAt}, which is
|
|
2771
|
+
* what those builds wrote, and is only a last-resort approximation.
|
|
2772
|
+
*/
|
|
2773
|
+
hostStartedAt?: number;
|
|
2745
2774
|
}
|
|
2746
2775
|
/** Absolute path of the host heartbeat file. */
|
|
2747
2776
|
declare function workbuddyHostHeartbeatPath(): string;
|
|
@@ -2759,11 +2788,29 @@ declare function processStartTimeMs(pid: number): number | undefined;
|
|
|
2759
2788
|
*
|
|
2760
2789
|
* A PID can be reused after a crash, so the recorded start time is compared
|
|
2761
2790
|
* against the live process: a different start time means a different process.
|
|
2791
|
+
*
|
|
2792
|
+
* ISSUE #174 — the reference used to be `registeredAt`, the moment the plugin
|
|
2793
|
+
* WROTE the heartbeat, and the comparison was `|startedAt − registeredAt| < 60s`.
|
|
2794
|
+
* Those are two different quantities: `registeredAt` is when the host finished
|
|
2795
|
+
* booting and mounted this plugin, which on a real machine is minutes after the
|
|
2796
|
+
* process started. Measured by the reporter on Windows 11: process start
|
|
2797
|
+
* 19:47:20, heartbeat written 19:58:45 — 684,817 ms apart — so `doctor` and
|
|
2798
|
+
* `status` reported `hostBundle: "stale"` for a host that was alive and had
|
|
2799
|
+
* just written the file. The `hostStartedAt` field exists so the comparison has
|
|
2800
|
+
* the right reference; `registeredAt` is only read for heartbeats written by an
|
|
2801
|
+
* older build, where it is the sole (and merely near) signal available.
|
|
2762
2802
|
*/
|
|
2763
2803
|
declare function isHeartbeatProcessAlive(heartbeat: WorkBuddyHostHeartbeat): boolean;
|
|
2764
2804
|
/** Read the heartbeat; absent or unparsable files report undefined. */
|
|
2765
2805
|
declare function readHostHeartbeat(): Promise<WorkBuddyHostHeartbeat | undefined>;
|
|
2766
|
-
/**
|
|
2806
|
+
/**
|
|
2807
|
+
* Write the heartbeat for the current process.
|
|
2808
|
+
*
|
|
2809
|
+
* `hostStartedAt` is captured HERE, from this process, so the later staleness
|
|
2810
|
+
* check compares like with like (issue #174). Sampled on every write rather
|
|
2811
|
+
* than cached: the value is cheap, and a module-level cache would be wrong the
|
|
2812
|
+
* moment this function is exercised from a child process.
|
|
2813
|
+
*/
|
|
2767
2814
|
declare function writeHostHeartbeat(): Promise<void>;
|
|
2768
2815
|
/** Remove the heartbeat; called when the plugin is disposed. */
|
|
2769
2816
|
declare function clearHostHeartbeat(): Promise<void>;
|
|
@@ -4981,8 +5028,35 @@ interface WorkBuddyRegionState {
|
|
|
4981
5028
|
lastCatalog?: WorkBuddyModelInfo[];
|
|
4982
5029
|
/** The user's selection in this region, as model ids. */
|
|
4983
5030
|
enabledModelIds?: string[];
|
|
4984
|
-
/**
|
|
5031
|
+
/**
|
|
5032
|
+
* Model ids the user opted into image input for this region.
|
|
5033
|
+
*
|
|
5034
|
+
* Every id here is honoured. What the SAVED array cannot express is the
|
|
5035
|
+
* difference between "I looked at this model and unticked it" and "this model
|
|
5036
|
+
* did not exist when the list was last refreshed" — so the runtime also adds
|
|
5037
|
+
* a model this build's vendor table classifies as verified-multimodal, UNLESS
|
|
5038
|
+
* {@link imageSelectionExplicit} says the user has edited these boxes. See
|
|
5039
|
+
* `effectiveImageIds` (issue #168); the seed never rewrites this array.
|
|
5040
|
+
*/
|
|
4985
5041
|
imageModelIds?: string[];
|
|
5042
|
+
/**
|
|
5043
|
+
* Whether the image boxes have been edited by a human (issue #168).
|
|
5044
|
+
*
|
|
5045
|
+
* `imageModelIds` alone cannot distinguish a DECISION from a leftover refresh
|
|
5046
|
+
* snapshot: the array is written both by the card's save (a judgement) and by
|
|
5047
|
+
* a catalog refresh (what the roster said on that day). Without that
|
|
5048
|
+
* distinction a model the roster gained after the last refresh was stamped
|
|
5049
|
+
* `multimodal: false` purely by its ABSENCE, `workBuddyModelInput` answered
|
|
5050
|
+
* `['text']`, and the host refused the attachment with
|
|
5051
|
+
* `MODEL_DOES_NOT_SUPPORT_IMAGES` — 「这个模型不支持图片」 — for a model the
|
|
5052
|
+
* plugin's own table calls verified-multimodal.
|
|
5053
|
+
*
|
|
5054
|
+
* `false`/absent means "nobody has chosen yet, so the table may fill the gaps";
|
|
5055
|
+
* a card save sets `true`, after which this array is the whole answer — which
|
|
5056
|
+
* is what keeps an explicit UNTICK of a verified-multimodal model from being
|
|
5057
|
+
* silently re-ticked on the next read.
|
|
5058
|
+
*/
|
|
5059
|
+
imageSelectionExplicit?: boolean;
|
|
4986
5060
|
/**
|
|
4987
5061
|
* Model ids whose `off` thinking level is offered (issue #34). Seeded from
|
|
4988
5062
|
* the built-in rule (upstream declaration minus the models known to reject
|
|
@@ -5206,12 +5280,13 @@ interface Config {
|
|
|
5206
5280
|
*
|
|
5207
5281
|
* 这条失败**不换号也不重试**(`isFailoverWorthy` 刻意把它排除在外:网关匹配的
|
|
5208
5282
|
* 是消息开头,换账号只会每个账号白烧一次请求),所以这一轮就此结束,用户看到的
|
|
5209
|
-
* 是「本轮运行失败400: … 被网关的渠道校验拦截
|
|
5210
|
-
*
|
|
5211
|
-
*
|
|
5283
|
+
* 是「本轮运行失败400: … 被网关的渠道校验拦截 …」。固定指纹由 `prepareChatBody`
|
|
5284
|
+
* 的按句改词处理;这条开关只是**最后兜底**:只有被拒内容刚好随轮次变化的窄情况
|
|
5285
|
+
* 才可能靠新回合碰过去。固定 system 指纹每轮相同,重发也救不了(#163 实测)。
|
|
5212
5286
|
*
|
|
5213
5287
|
* 默认关闭的理由与 {@link Config.usageIdentity} 相同:它会在用户没有发消息的
|
|
5214
|
-
*
|
|
5288
|
+
* 情况下花掉真实请求,属于必须显式授权的开销。上限默认 1 次:1 次救不了,3 次也
|
|
5289
|
+
* 救不了,旧默认 3 只会把同一个 400 付三遍。
|
|
5215
5290
|
*/
|
|
5216
5291
|
channelRejectContinue?: {
|
|
5217
5292
|
/**
|
|
@@ -5219,7 +5294,7 @@ interface Config {
|
|
|
5219
5294
|
*/
|
|
5220
5295
|
enabled?: boolean;
|
|
5221
5296
|
/**
|
|
5222
|
-
* 连续自动续发的次数上限(默认
|
|
5297
|
+
* 连续自动续发的次数上限(默认 1,夹在 1..10)。
|
|
5223
5298
|
*
|
|
5224
5299
|
* 「连续」是关键:一次成功回合、或者用户自己发了一条消息,计数立刻清零。
|
|
5225
5300
|
* 所以上限拦的是「一直过不去就一直试」,而不是这个会话一生的总次数。
|