dsh-input-assistant 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/LICENSE +21 -21
  3. package/README.en.md +100 -0
  4. package/README.md +215 -184
  5. package/cordis.patch.yml +3 -3
  6. package/docs/CHANGELOG-0.1.2.md +102 -102
  7. package/docs/evaluation.md +111 -111
  8. package/docs/images/dark.png +0 -0
  9. package/docs/images/light.png +0 -0
  10. package/docs/images/optimize.png +0 -0
  11. package/docs/references.md +39 -39
  12. package/docs/smoke-comparison.md +37 -37
  13. package/docs/validation.md +180 -100
  14. package/lib/browser/components.d.ts +1 -1
  15. package/lib/browser/controller.d.ts +47 -4
  16. package/lib/browser/dom.d.ts +1 -1
  17. package/lib/browser/index.d.ts +1 -1
  18. package/lib/browser/public.d.ts +2 -2
  19. package/lib/browser/runtime.d.ts +4 -2
  20. package/lib/browser/settings.d.ts +1 -1
  21. package/lib/browser/styles.d.ts +1 -1
  22. package/lib/client.js +1164 -222
  23. package/lib/client.meta.json +40 -40
  24. package/lib/core/config.d.ts +1 -1
  25. package/lib/core/context.d.ts +1 -1
  26. package/lib/core/generation.d.ts +2 -2
  27. package/lib/core/index.d.ts +9 -8
  28. package/lib/core/models.d.ts +15 -0
  29. package/lib/core/protection.d.ts +3 -4
  30. package/lib/core/scheduler.d.ts +1 -1
  31. package/lib/core/snapshot.d.ts +1 -1
  32. package/lib/core/types.d.ts +11 -0
  33. package/lib/core.js +280 -53
  34. package/lib/core.js.map +2 -2
  35. package/lib/host/rpc.d.ts +2 -0
  36. package/lib/host/runtime.d.ts +5 -3
  37. package/lib/host/storage.d.ts +11 -3
  38. package/lib/host/transcript.d.ts +1 -1
  39. package/lib/index.d.ts +5 -3
  40. package/lib/index.js +451 -156
  41. package/lib/index.js.map +4 -4
  42. package/package.json +10 -3
package/lib/core.js CHANGED
@@ -5,6 +5,7 @@ var DEFAULT_CONFIG = Object.freeze({
5
5
  optimizationStrength: "standard",
6
6
  nextEnabled: true,
7
7
  completionEnabled: true,
8
+ predictionDetail: "standard",
8
9
  displayMode: "ghost",
9
10
  debounceMs: 600,
10
11
  minIntervalMs: 2e3,
@@ -33,8 +34,8 @@ var numeric = {
33
34
  minIntervalMs: [500, 3e4],
34
35
  maxHistoryChars: [1e3, 64e3],
35
36
  maxDraftChars: [100, 32e3],
36
- maxNextChars: [20, 300],
37
- maxCompletionChars: [10, 200],
37
+ maxNextChars: [20, 1e3],
38
+ maxCompletionChars: [10, 500],
38
39
  optimizeTimeoutMs: [1e3, 12e4],
39
40
  suggestionTimeoutMs: [1e3, 6e4]
40
41
  };
@@ -46,6 +47,7 @@ function validate(config) {
46
47
  }
47
48
  if (!["ghost", "panel"].includes(config.displayMode)) throw new AssistantError("CONFIG", "\u663E\u793A\u65B9\u5F0F\u5FC5\u987B\u662F ghost \u6216 panel");
48
49
  if (!["light", "standard", "deep"].includes(config.optimizationStrength)) throw new AssistantError("CONFIG", "\u4F18\u5316\u5F3A\u5EA6\u5FC5\u987B\u662F light\u3001standard \u6216 deep");
50
+ if (!["concise", "standard", "detailed"].includes(config.predictionDetail)) throw new AssistantError("CONFIG", "\u9884\u6D4B\u8BE6\u7565\u5EA6\u5FC5\u987B\u662F concise\u3001standard \u6216 detailed");
49
51
  for (const [key, [min, max]] of Object.entries(numeric)) {
50
52
  const value = config[key];
51
53
  if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max) {
@@ -67,6 +69,7 @@ function resolveStoredConfig(input) {
67
69
  const record = input && typeof input === "object" && !Array.isArray(input) ? input : {};
68
70
  const config = { ...DEFAULT_CONFIG };
69
71
  for (const key of Object.keys(DEFAULT_CONFIG)) {
72
+ if (key === "provider" || key === "model") continue;
70
73
  if (!(key in record)) continue;
71
74
  const value = record[key];
72
75
  const candidate = { ...DEFAULT_CONFIG, [key]: value };
@@ -77,9 +80,11 @@ function resolveStoredConfig(input) {
77
80
  }
78
81
  config[key] = typeof value === "string" ? value.trim() : value;
79
82
  }
80
- if (Boolean(config.provider) !== Boolean(config.model)) {
81
- config.provider = "";
82
- config.model = "";
83
+ try {
84
+ const route = validate({ ...DEFAULT_CONFIG, provider: record.provider ?? "", model: record.model ?? "" });
85
+ config.provider = route.provider;
86
+ config.model = route.model;
87
+ } catch {
83
88
  }
84
89
  return config;
85
90
  }
@@ -117,7 +122,7 @@ function buildContext(history, budget = 16e3) {
117
122
  }
118
123
 
119
124
  // src/core/protection.ts
120
- var FILE_EXTENSIONS = [
125
+ var FILE_EXTENSIONS = /* @__PURE__ */ new Set([
121
126
  "json",
122
127
  "jsonc",
123
128
  "json5",
@@ -207,8 +212,8 @@ var FILE_EXTENSIONS = [
207
212
  "stylelintrc",
208
213
  "babelrc",
209
214
  "browserslistrc"
210
- ].join("|");
211
- var NAMED_FILES = [
215
+ ]);
216
+ var NAMED_FILES = /* @__PURE__ */ new Set([
212
217
  "LICENSE",
213
218
  "LICENCE",
214
219
  "README",
@@ -225,28 +230,193 @@ var NAMED_FILES = [
225
230
  "Vagrantfile",
226
231
  "Jenkinsfile",
227
232
  "Brewfile"
228
- ].join("|");
229
- var pathSegment = "[\\p{L}\\p{N}\\p{M}_.@+~-]";
230
- function protectText(text, locked = []) {
233
+ ]);
234
+ var DOTFILES = [
235
+ ".env",
236
+ ".npmrc",
237
+ ".nvmrc",
238
+ ".editorconfig",
239
+ ".gitignore",
240
+ ".gitattributes",
241
+ ".gitmodules",
242
+ ".prettierrc",
243
+ ".eslintrc",
244
+ ".stylelintrc",
245
+ ".babelrc",
246
+ ".browserslistrc"
247
+ ];
248
+ var candidatePattern = /[\p{L}\p{N}\p{M}_.@+~\\/-]+/uy;
249
+ var cjk = "[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}]";
250
+ var cjkCharacter = new RegExp(cjk, "u");
251
+ var candidateEnding = new RegExp(`(?:\\.[A-Za-z][A-Za-z0-9]{0,11}|${[...NAMED_FILES].join("|")})(?=${cjk}|$)`, "gu");
252
+ function knownFile(value) {
253
+ const dot = value.lastIndexOf(".");
254
+ return NAMED_FILES.has(value) || DOTFILES.some((name) => value === name || value.startsWith(name + ".")) || dot > 0 && FILE_EXTENSIONS.has(value.slice(dot + 1).toLowerCase());
255
+ }
256
+ function pathOrFile(value) {
257
+ const slash = Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\"));
258
+ if (slash < 0) return knownFile(value);
259
+ if (/^(?:\.{1,2}[\\/]|[\\/])/.test(value)) return value.length > slash + 1 || slash > 0;
260
+ const tail = value.slice(slash + 1), dot = tail.lastIndexOf(".");
261
+ if (dot >= 0 && /^[A-Za-z][A-Za-z0-9]{0,11}$/.test(tail.slice(dot + 1))) return true;
262
+ let separators = 0;
263
+ for (const char of value) if (char === "/" || char === "\\") separators++;
264
+ return separators >= 2;
265
+ }
266
+ function literalStart(value) {
267
+ const slash = value.search(/[\\/]/);
268
+ const first = slash < 0 ? value : value.slice(0, slash);
269
+ if (slash >= 0) {
270
+ const relative = first.match(/(?:\.{1,2}|~)$/);
271
+ if (relative) return relative.index;
272
+ }
273
+ const dot = first.indexOf(".");
274
+ const stem = dot < 0 ? first : first.slice(0, dot);
275
+ let start = 0, previous = "", offset = 0;
276
+ for (const char of stem) {
277
+ if (cjkCharacter.test(previous) && /[A-Za-z0-9_@+~-]/.test(char)) start = offset;
278
+ previous = char;
279
+ offset += char.length;
280
+ }
281
+ if (!start && dot > 0 && DOTFILES.some((name) => first.slice(dot) === name || first.slice(dot).startsWith(name + ".")) && Array.from(stem).every((char) => cjkCharacter.test(char))) return dot;
282
+ return start;
283
+ }
284
+ function candidateRanges(value, base) {
285
+ const ranges = [];
286
+ let cursor = 0;
287
+ candidateEnding.lastIndex = 0;
288
+ for (const ending of value.matchAll(candidateEnding)) {
289
+ const end = ending.index + ending[0].length;
290
+ let next = end;
291
+ while (next < value.length) {
292
+ const char = String.fromCodePoint(value.codePointAt(next));
293
+ if (!cjkCharacter.test(char)) break;
294
+ next += char.length;
295
+ }
296
+ if (next > end && /[\\/]/.test(value[next] ?? "")) continue;
297
+ const portion = value.slice(cursor, end);
298
+ const start = cursor + literalStart(portion);
299
+ if (pathOrFile(value.slice(start, end))) ranges.push({ start: base + start, end: base + end });
300
+ cursor = end;
301
+ }
302
+ if (cursor < value.length && /[.\\/]/.test(value.slice(cursor))) {
303
+ const portion = value.slice(cursor);
304
+ const start = cursor + literalStart(portion);
305
+ let end = value.length;
306
+ const tailStart = Math.max(start, Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\")) + 1);
307
+ const tail = value.slice(tailStart), boundary = tail.search(cjkCharacter);
308
+ if (tailStart >= start && boundary > 0 && /^[A-Za-z0-9_@+~.-]/.test(tail)) end = tailStart + boundary;
309
+ if (pathOrFile(value.slice(start, end))) ranges.push({ start: base + start, end: base + end });
310
+ }
311
+ return ranges;
312
+ }
313
+ function urlEnd(text, start) {
314
+ let end = start, parentheses = 0, brackets = 0;
315
+ while (end < text.length) {
316
+ const char = text[end];
317
+ if (/[\s<>"'`,。;;)]/.test(char)) break;
318
+ if (char === "(") parentheses++;
319
+ else if (char === ")") {
320
+ if (!parentheses) break;
321
+ parentheses--;
322
+ } else if (char === "[") brackets++;
323
+ else if (char === "]") {
324
+ if (!brackets) break;
325
+ brackets--;
326
+ }
327
+ end++;
328
+ }
329
+ while (end > start && /[.,!]/.test(text[end - 1])) end--;
330
+ return end;
331
+ }
332
+ function windowsPathEnd(text, start) {
333
+ const quote = text[start - 1];
334
+ if (quote === '"' || quote === "'") {
335
+ const closing = text.indexOf(quote, start);
336
+ if (closing >= 0 && !/[\r\n]/.test(text.slice(start, closing))) return closing;
337
+ }
338
+ let end = start + 3;
339
+ while (end < text.length && !/[\r\n<>"'`,。;;))\]!?!?::]/.test(text[end])) end++;
340
+ while (end > start && /[ \t]/.test(text[end - 1])) end--;
341
+ const value = text.slice(start, end);
342
+ const tailStart = Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\")) + 1;
343
+ const tail = value.slice(tailStart);
344
+ const dotfile = DOTFILES.some((name) => tail.startsWith(name) && /[. \t]/.test(tail[name.length] ?? ""));
345
+ let dot = -1;
346
+ for (let i = 0; i < tail.length; i++) {
347
+ if (tail[i] === ".") dot = i;
348
+ if (/[ \t]/.test(tail[i]) && (dotfile || dot >= 0 && i - dot <= 13 && FILE_EXTENSIONS.has(tail.slice(dot + 1, i).toLowerCase()) || i <= 20 && NAMED_FILES.has(tail.slice(0, i)))) return start + tailStart + i;
349
+ }
350
+ return end;
351
+ }
352
+ function automaticRanges(text) {
231
353
  const ranges = [];
232
- const patterns = [
233
- /```[^]*?(?:```|$)/g,
234
- /~~~[^]*?(?:~~~|$)/g,
235
- /`[^`\n]+`/g,
236
- /https?:\/\/[^\s<>"'))\],。;;]+/g,
237
- /[A-Za-z]:[\\/][^\s<>"',。;;))\]]+/g,
238
- /(?:\.{1,2}[\\/]|\/(?:[\p{L}\p{N}\p{M}_.-]+\/)+)[\p{L}\p{N}\p{M}_./\\@+-]+/gu,
239
- // Relative path carrying an extension: `src/index.ts`, `docs/validation.md`.
240
- // Requires at least one separator so plain prose words stay untouched.
241
- new RegExp(`(?:${pathSegment}+\\/)+${pathSegment}*\\.[A-Za-z][A-Za-z0-9]{0,11}`, "gu"),
242
- // Deep relative path without an extension: `src/core/generation`.
243
- new RegExp(`(?:${pathSegment}+\\/){2,}${pathSegment}+`, "gu"),
244
- // Bare file name with a known extension: `package.json`, `README.md`.
245
- new RegExp(`${pathSegment}+\\.(?:${FILE_EXTENSIONS})(?![A-Za-z0-9_])`, "giu"),
246
- // Well-known extensionless file names.
247
- new RegExp(`(?<![A-Za-z0-9_])(?:${NAMED_FILES})(?![A-Za-z0-9_])`, "g")
248
- ];
249
- for (const re of patterns) for (const m of text.matchAll(re)) ranges.push({ start: m.index, end: m.index + m[0].length });
354
+ let cursor = 0;
355
+ while (cursor < text.length) {
356
+ const start = cursor;
357
+ const fence = text.startsWith("```", cursor) ? "```" : text.startsWith("~~~", cursor) ? "~~~" : null;
358
+ if (fence) {
359
+ const closing = text.indexOf(fence, cursor + 3);
360
+ cursor = closing < 0 ? text.length : closing + 3;
361
+ ranges.push({ start, end: cursor });
362
+ continue;
363
+ }
364
+ if (text[cursor] === "`") {
365
+ let end = cursor + 1;
366
+ while (end < text.length && text[end] !== "`" && text[end] !== "\n") end++;
367
+ if (text[end] === "`" && end > cursor + 1) {
368
+ cursor = end + 1;
369
+ ranges.push({ start, end: cursor });
370
+ continue;
371
+ }
372
+ }
373
+ if (text[cursor] === '"' || text[cursor] === "'") {
374
+ const closing = text.indexOf(text[cursor], cursor + 1);
375
+ if (closing >= 0) {
376
+ const literal = text.slice(cursor + 1, closing);
377
+ if (!/[\r\n]/.test(literal) && (pathOrFile(literal) || /^[A-Za-z]:[\\/]/.test(literal))) {
378
+ ranges.push({ start: cursor + 1, end: closing });
379
+ cursor = closing + 1;
380
+ continue;
381
+ }
382
+ }
383
+ }
384
+ const scheme = text.slice(cursor, cursor + 8).toLowerCase();
385
+ if (scheme.startsWith("https://") || scheme.startsWith("http://")) {
386
+ cursor = urlEnd(text, cursor);
387
+ ranges.push({ start, end: cursor });
388
+ continue;
389
+ }
390
+ if (/[A-Za-z]/.test(text[cursor]) && text[cursor + 1] === ":" && /[\\/]/.test(text[cursor + 2] ?? "")) {
391
+ cursor = windowsPathEnd(text, cursor);
392
+ ranges.push({ start, end: cursor });
393
+ continue;
394
+ }
395
+ candidatePattern.lastIndex = cursor;
396
+ const match = candidatePattern.exec(text);
397
+ if (match) {
398
+ cursor = candidatePattern.lastIndex;
399
+ const fenceOffset = match[0].indexOf("~~~");
400
+ if (fenceOffset >= 0) cursor = start + fenceOffset;
401
+ else if (text[cursor] === ":") {
402
+ if (text.startsWith("//", cursor + 1)) {
403
+ const ending = text.slice(Math.max(start, cursor - 5), cursor).toLowerCase();
404
+ if (ending.endsWith("https")) cursor -= 5;
405
+ else if (ending.endsWith("http")) cursor -= 4;
406
+ else if (/[A-Za-z]/.test(text[cursor - 1] ?? "")) cursor--;
407
+ } else if (/[\\/]/.test(text[cursor + 1] ?? "") && /[A-Za-z]/.test(text[cursor - 1] ?? "")) cursor--;
408
+ }
409
+ if (cursor === start) continue;
410
+ let end = cursor;
411
+ if (!/[\\/]\.{1,2}$/.test(text.slice(start, cursor))) while (end > start && text[end - 1] === ".") end--;
412
+ const value = text.slice(start, end);
413
+ if (value) ranges.push(...candidateRanges(value, start));
414
+ } else cursor++;
415
+ }
416
+ return ranges;
417
+ }
418
+ function protectText(text, locked = []) {
419
+ const ranges = automaticRanges(text);
250
420
  for (const literal of locked) {
251
421
  if (typeof literal !== "string" || !literal.length || !text.includes(literal)) throw new AssistantError("LOCK_SCOPE", "\u9501\u5B9A\u7247\u6BB5\u5DF2\u53D8\u5316\u6216\u4E0D\u5728\u672C\u6B21\u4F18\u5316\u8303\u56F4\u5185");
252
422
  let start = text.indexOf(literal);
@@ -262,8 +432,14 @@ function protectText(text, locked = []) {
262
432
  if (prev && r.start < prev.end) prev.end = Math.max(prev.end, r.end);
263
433
  else merged.push({ ...r });
264
434
  }
265
- let prefix = "DSH_KEEP_";
266
- while (text.includes(prefix)) prefix = "_" + prefix;
435
+ let longestPrefix = -1, occurrence = text.indexOf("DSH_KEEP_");
436
+ while (occurrence >= 0) {
437
+ let start = occurrence;
438
+ while (start > 0 && text[start - 1] === "_") start--;
439
+ longestPrefix = Math.max(longestPrefix, occurrence - start);
440
+ occurrence = text.indexOf("DSH_KEEP_", occurrence + 9);
441
+ }
442
+ const prefix = "_".repeat(longestPrefix + 1) + "DSH_KEEP_";
267
443
  const fragments = [];
268
444
  let cursor = 0, masked = "";
269
445
  for (const r of merged) {
@@ -276,19 +452,20 @@ function protectText(text, locked = []) {
276
452
  return { original: text, masked: masked + text.slice(cursor), fragments };
277
453
  }
278
454
  function restoreProtected(output, protectedText) {
279
- let remaining = output;
280
- for (const { token } of protectedText.fragments) {
281
- if (remaining.split(token).length !== 2) throw new AssistantError("PROTECTION", "\u751F\u6210\u7ED3\u679C\u672A\u5B8C\u6574\u4FDD\u7559\u9501\u5B9A\u5185\u5BB9\uFF0C\u5DF2\u4FDD\u7559\u539F\u7A3F");
282
- remaining = remaining.replace(token, "");
283
- }
284
- for (const marker of remaining.match(/⟪_*DSH_KEEP_\d+⟫/gu) ?? []) {
285
- if (!protectedText.original.includes(marker)) throw new AssistantError("PROTECTION", "\u751F\u6210\u7ED3\u679C\u5305\u542B\u65E0\u6548\u7684\u4FDD\u62A4\u6807\u8BB0\uFF0C\u5DF2\u4FDD\u7559\u539F\u7A3F");
286
- }
287
- let restored = output;
288
- for (const { token, original } of protectedText.fragments) {
289
- if (restored.split(token).length !== 2) throw new AssistantError("PROTECTION", "\u751F\u6210\u7ED3\u679C\u672A\u5B8C\u6574\u4FDD\u7559\u9501\u5B9A\u5185\u5BB9\uFF0C\u5DF2\u4FDD\u7559\u539F\u7A3F");
290
- restored = restored.replace(token, () => original);
291
- }
455
+ const fragments = new Map(protectedText.fragments.map((fragment) => [fragment.token, fragment.original]));
456
+ const existing = new Set(protectedText.original.match(/⟪_*DSH_KEEP_\d+⟫/gu) ?? []);
457
+ const seen = /* @__PURE__ */ new Set();
458
+ const restored = output.replace(/⟪_*DSH_KEEP_\d+⟫/gu, (token) => {
459
+ const original = fragments.get(token);
460
+ if (original === void 0) {
461
+ if (!existing.has(token)) throw new AssistantError("PROTECTION", "\u751F\u6210\u7ED3\u679C\u5305\u542B\u65E0\u6548\u7684\u4FDD\u62A4\u6807\u8BB0\uFF0C\u5DF2\u4FDD\u7559\u539F\u7A3F");
462
+ return token;
463
+ }
464
+ if (seen.has(token)) throw new AssistantError("PROTECTION", "\u751F\u6210\u7ED3\u679C\u672A\u5B8C\u6574\u4FDD\u7559\u9501\u5B9A\u5185\u5BB9\uFF0C\u5DF2\u4FDD\u7559\u539F\u7A3F");
465
+ seen.add(token);
466
+ return original;
467
+ });
468
+ if (seen.size !== fragments.size) throw new AssistantError("PROTECTION", "\u751F\u6210\u7ED3\u679C\u672A\u5B8C\u6574\u4FDD\u7559\u9501\u5B9A\u5185\u5BB9\uFF0C\u5DF2\u4FDD\u7559\u539F\u7A3F");
292
469
  return restored;
293
470
  }
294
471
 
@@ -309,6 +486,13 @@ function prepareGeneration(request, history, config) {
309
486
  if (charCount(target) > config.maxDraftChars) throw new AssistantError("TOO_LONG", `\u672C\u6B21\u6587\u672C\u8D85\u8FC7 ${config.maxDraftChars} \u5B57\u7B26\uFF0C\u8BF7\u9009\u4E2D\u8F83\u77ED\u7247\u6BB5\u4F18\u5316`);
310
487
  if (request.operation === "optimize" && !target.trim()) throw new AssistantError("EMPTY", "\u8BF7\u5148\u8F93\u5165\u6216\u9009\u4E2D\u9700\u8981\u4F18\u5316\u7684\u6587\u5B57");
311
488
  if (request.operation === "complete" && !request.draft.trim()) throw new AssistantError("EMPTY", "\u6CA1\u6709\u53EF\u7EED\u5199\u7684\u8349\u7A3F");
489
+ const detail = config.predictionDetail;
490
+ const suggestionCap = request.operation === "next" ? config.maxNextChars : config.maxCompletionChars;
491
+ const detailGuidance = {
492
+ concise: "Detail level: CONCISE. Prefer one brief sentence or phrase. Do not add scope merely to fill the character budget.",
493
+ standard: "Detail level: STANDARD. Use a natural, complete expression with only the detail needed for the current intent.",
494
+ detailed: "Detail level: DETAILED. Explain only already-supported intent more fully; short paragraphs or a structured list are allowed when useful. There is no minimum length. Never add tasks, constraints, permissions, deliverables, or commitments merely to make the message longer."
495
+ }[detail];
312
496
  let system = common, protection = null;
313
497
  if (request.operation === "optimize") {
314
498
  protection = protectText(target, request.lockedFragments);
@@ -333,18 +517,22 @@ function prepareGeneration(request, history, config) {
333
517
  "Consider answering the latest question, a natural follow-up question, correcting a misunderstanding, or proceeding with an explicitly requested unfinished step.",
334
518
  "A question or correction is allowed when supported. Do not automatically propose commits, pushing, testing, or new features just because code was produced.",
335
519
  `If no single continuation is well-supported, or the task is naturally finished, output ${NO_SUGGESTION}. Do not guess an unresolved choice.`,
336
- `Return one short message of at most ${config.maxNextChars} characters.`,
520
+ `Return one message of at most ${suggestionCap} Unicode characters. This is a hard maximum, not a target or minimum.`,
521
+ detailGuidance,
522
+ detail === "detailed" ? "Internal line breaks are allowed for supported structure; do not add blank padding lines." : "Return a single line with no line breaks.",
337
523
  "Examples: user requested hit rate AND invalidation, only hit rate was explained -> \u518D\u89E3\u91CA\u4E00\u4E0B\u7F13\u5B58\u5931\u6548\u3002",
338
524
  "User said explanation only, assistant proposes file edits -> \u5148\u4E0D\u8981\u6539\u6587\u4EF6\uFF0C\u8BF7\u53EA\u89E3\u91CA\u539F\u56E0\u3002",
339
525
  `Assistant asks Redis or local cache, no preference in history -> ${NO_SUGGESTION}.`
340
526
  ].join("\n");
341
527
  } else {
342
528
  system += "\n" + [
343
- "Complete the user's current draft by returning ONLY a short suffix to append exactly at its end.",
529
+ "Complete the user's current draft by returning ONLY a suffix to append exactly at its end.",
344
530
  "The draft is the strongest constraint. Do not repeat its prefix, rewrite existing text, introduce a new topic, or answer the question.",
345
531
  "Preserve necessary leading whitespace in the suffix. A complete message does not need an arbitrary extra requirement.",
346
532
  `If the intended continuation is uncertain or the draft is already complete, output ${NO_SUGGESTION}.`,
347
- `Return at most ${config.maxCompletionChars} characters, with no explanation.`,
533
+ `Return at most ${suggestionCap} Unicode characters, with no explanation. This is a hard maximum, not a target or minimum.`,
534
+ detailGuidance,
535
+ detail === "detailed" ? "Line breaks are allowed only when they naturally continue the existing draft structure. Preserve a necessary leading line break, but do not start an unrelated list or task." : "Return a single line with no line breaks.",
348
536
  "Examples: draft \u8BF7\u89E3\u91CA\u8FD9\u6BB5\u4EE3\u7801\u7684\u65F6\u95F4 -> \u590D\u6742\u5EA6\u3002",
349
537
  `Draft Please explain the cause without editing files. -> ${NO_SUGGESTION}.`
350
538
  ].join("\n");
@@ -363,11 +551,46 @@ function prepareGeneration(request, history, config) {
363
551
  config,
364
552
  protection,
365
553
  timeoutMs: request.operation === "optimize" ? config.optimizeTimeoutMs : config.suggestionTimeoutMs,
366
- maxOutputTokens: request.operation === "optimize" ? 8192 : request.operation === "next" ? 256 : 192
554
+ // A Unicode character may require several tokens, especially for CJK and
555
+ // emoji. Keep the transport budget large enough for the explicit text cap.
556
+ maxOutputTokens: request.operation === "optimize" ? 8192 : Math.max(request.operation === "next" ? 128 : 96, suggestionCap * 4 + 32)
367
557
  };
368
558
  }
559
+ function hasUnpairedSurrogate(text) {
560
+ for (let i = 0; i < text.length; i++) {
561
+ const code = text.charCodeAt(i);
562
+ if (code >= 55296 && code <= 56319) {
563
+ const next = text.charCodeAt(++i);
564
+ if (!(next >= 56320 && next <= 57343)) return true;
565
+ } else if (code >= 56320 && code <= 57343) return true;
566
+ }
567
+ return false;
568
+ }
569
+ function cleanOptimizationOutput(raw, prepared) {
570
+ let cleaned = raw.trim();
571
+ const original = prepared.protection.original.trim();
572
+ const masked = prepared.protection.masked.trim();
573
+ if (cleaned === masked) return cleaned;
574
+ const fence = cleaned.match(/^```(?:markdown|prompt|text)?\r?\n([\s\S]*?)\r?\n```$/i);
575
+ if (fence) cleaned = fence[1].trim();
576
+ const preamble = /^(?:(?:好的|好的,|已为您|根据您的要求|针对您的需求|我为您)?(?:优化|改写|整理|修改|调整)后的?(?:提示词|prompt|内容|表达|文本)?(?:如下|为)?[::\s\n]+|Here(?:'s| is) (?:the |an )?(?:optimized|improved|rewritten) (?:prompt|version)[::\s\n]+)/i;
577
+ const prefix = cleaned.match(preamble)?.[0];
578
+ if (prefix && !original.toLowerCase().startsWith(prefix.trim().toLowerCase())) cleaned = cleaned.slice(prefix.length).trim();
579
+ for (const [start, end] of [['"', '"'], ["\u201C", "\u201D"], ["`", "`"]]) {
580
+ if (!cleaned.startsWith(start) || !cleaned.endsWith(end) || cleaned.length < 2) continue;
581
+ const inner = cleaned.slice(start.length, -end.length).trim();
582
+ const originalWrapped = masked.startsWith(start) && masked.endsWith(end);
583
+ if (!originalWrapped || inner.startsWith(start) && inner.endsWith(end)) cleaned = inner;
584
+ break;
585
+ }
586
+ return cleaned;
587
+ }
369
588
  function finishGeneration(raw, prepared) {
370
589
  if (typeof raw !== "string") throw new AssistantError("OUTPUT", "\u6A21\u578B\u672A\u8FD4\u56DE\u6709\u6548\u6587\u5B57");
590
+ if (hasUnpairedSurrogate(raw)) {
591
+ if (prepared.operation === "optimize") throw new AssistantError("OUTPUT", "\u6A21\u578B\u8FD4\u56DE\u4E86\u4E0D\u5B8C\u6574\u5B57\u7B26\uFF0C\u5DF2\u4FDD\u7559\u539F\u7A3F");
592
+ return null;
593
+ }
371
594
  const trimmed = raw.trim();
372
595
  if (!trimmed || trimmed === NO_SUGGESTION) {
373
596
  if (prepared.operation === "optimize") throw new AssistantError("EMPTY_OUTPUT", "\u6A21\u578B\u672A\u8FD4\u56DE\u4F18\u5316\u6587\u5B57\uFF0C\u5DF2\u4FDD\u7559\u539F\u7A3F");
@@ -375,16 +598,20 @@ function finishGeneration(raw, prepared) {
375
598
  }
376
599
  if (prepared.operation === "optimize") {
377
600
  if (raw.includes(NO_SUGGESTION)) throw new AssistantError("OUTPUT", "\u6A21\u578B\u8F93\u51FA\u4E0D\u5B8C\u6574\uFF0C\u5DF2\u4FDD\u7559\u539F\u7A3F");
378
- const output = restoreProtected(trimmed, prepared.protection);
601
+ const cleaned = cleanOptimizationOutput(trimmed, prepared);
602
+ if (!cleaned.trim()) throw new AssistantError("EMPTY_OUTPUT", "\u6A21\u578B\u672A\u8FD4\u56DE\u4F18\u5316\u6587\u5B57\uFF0C\u5DF2\u4FDD\u7559\u539F\u7A3F");
603
+ const output = restoreProtected(cleaned, prepared.protection);
604
+ if (hasUnpairedSurrogate(output)) throw new AssistantError("OUTPUT", "\u4F18\u5316\u7ED3\u679C\u5305\u542B\u4E0D\u5B8C\u6574\u5B57\u7B26\uFF0C\u5DF2\u4FDD\u7559\u539F\u7A3F");
379
605
  if (charCount(output) > prepared.config.maxDraftChars * 2) throw new AssistantError("OUTPUT", "\u4F18\u5316\u7ED3\u679C\u8FC7\u957F\uFF0C\u5DF2\u4FDD\u7559\u539F\u7A3F");
380
606
  return output;
381
607
  }
382
608
  const cap = prepared.operation === "next" ? prepared.config.maxNextChars : prepared.config.maxCompletionChars;
383
- if (charCount(raw) > cap || /[\r\n]/.test(trimmed) || /^(?:```|\{\s*"|<think>|(?:建议|预测|续写|Suggestion|Prediction)\s*[::])/i.test(trimmed) || trimmed.includes(NO_SUGGESTION)) return null;
609
+ const allowNewlines = prepared.config.predictionDetail === "detailed";
610
+ if (charCount(raw) > cap || !allowNewlines && /[\r\n]/.test(raw) || /^(?:```|\{\s*"|<think>|(?:建议|预测|续写|Suggestion|Prediction)\s*[::])/i.test(trimmed) || trimmed.includes(NO_SUGGESTION)) return null;
384
611
  if (prepared.operation === "next") return trimmed;
385
- if (trimmed.startsWith(prepared.request.draft.trim()) || prepared.request.draft.endsWith(raw)) return null;
386
- if (/[\uD800-\uDBFF]$/.test(raw) || /^[\uDC00-\uDFFF]/.test(raw)) return null;
387
- return raw.replace(/[ \t]+$/, "");
612
+ const draft = prepared.request.draft.trim();
613
+ if (charCount(draft) >= 4 && trimmed.startsWith(draft) || prepared.request.draft.endsWith(raw)) return null;
614
+ return raw.replace(/\s+$/u, "");
388
615
  }
389
616
 
390
617
  // src/core/scheduler.ts