@yeaft/webchat-agent 0.1.794 → 0.1.796

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.794",
3
+ "version": "0.1.796",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/unify/engine.js CHANGED
@@ -31,9 +31,8 @@ import { readSummary as readScopeSummary } from './memory/store-v2.js';
31
31
  import { runAdjust } from './memory/adjust.js';
32
32
  import { isVpSeedBackfillStub } from './memory/seed-backfill.js';
33
33
  import { runStopHooks } from './stop-hooks.js';
34
- // H2.f.5: threads/ retired. Persisted messages still carry a `threadId`
35
- // field for back-compat with old conversation files; new writes always use
36
- // the constant 'main'.
34
+ // Default thread marker for legacy / non-group flows. Group VP runtime may
35
+ // pass a real threadId per (groupId, vpId, threadId) engine instance.
37
36
  const MAIN_THREAD_ID = 'main';
38
37
  import { pickEffort, parseEffortPrefix } from './effort.js';
39
38
  import { normalizeEffort, resolveContextWindow } from './models.js';
@@ -313,6 +312,12 @@ export class Engine {
313
312
  #reflectedTurns = new Set();
314
313
  #__queryCounter = 0;
315
314
 
315
+ /** @type {string} */
316
+ #currentThreadId = MAIN_THREAD_ID;
317
+
318
+ /** @type {Array<{content:string|Array, preview:string}>} */
319
+ #pendingUserMessages = [];
320
+
316
321
  /**
317
322
  * Per-group "adjust has run at least once this engine lifetime" flag.
318
323
  * Keyed by groupId (or 'default'). The first turn always runs adjust;
@@ -950,9 +955,9 @@ export class Engine {
950
955
  if (!this.#conversationStore) return;
951
956
  if (this.#config._readOnly) return;
952
957
 
953
- // H2.f.5: threads retired. Persisted messages still carry threadId
954
- // for back-compat with old conversation files; new writes always use 'main'.
955
- const threadId = MAIN_THREAD_ID;
958
+ // Persist with the active runtime thread. Legacy / non-group flows use
959
+ // MAIN_THREAD_ID; group VP flows pass their classified threadId.
960
+ const threadId = this.#currentThreadId || MAIN_THREAD_ID;
956
961
 
957
962
  // Persist user message — unless an upstream caller (e.g. the group
958
963
  // coordinator) has already done so for this turn.
@@ -1093,6 +1098,33 @@ export class Engine {
1093
1098
  }
1094
1099
  }
1095
1100
 
1101
+ #drainPendingUserMessages(drainPendingUserMessages) {
1102
+ const pending = [];
1103
+ if (typeof drainPendingUserMessages === 'function') {
1104
+ try {
1105
+ const drained = drainPendingUserMessages();
1106
+ if (Array.isArray(drained)) pending.push(...drained);
1107
+ } catch {
1108
+ // Best-effort hook; a bad bridge callback must not kill the engine loop.
1109
+ }
1110
+ }
1111
+ if (this.#pendingUserMessages.length > 0) {
1112
+ pending.push(...this.#pendingUserMessages.splice(0));
1113
+ }
1114
+ return pending
1115
+ .map((item) => {
1116
+ if (typeof item === 'string') return { content: item, preview: item };
1117
+ if (!item || typeof item !== 'object') return null;
1118
+ const content = item.content ?? item.text;
1119
+ if (typeof content !== 'string' && !Array.isArray(content)) return null;
1120
+ const preview = typeof item.preview === 'string'
1121
+ ? item.preview
1122
+ : (typeof content === 'string' ? content : '[content blocks]');
1123
+ return { content, preview };
1124
+ })
1125
+ .filter(Boolean);
1126
+ }
1127
+
1096
1128
  /**
1097
1129
  * Run a query — the main loop.
1098
1130
  *
@@ -1122,7 +1154,7 @@ export class Engine {
1122
1154
  * string-prompt shape (no regression for existing callers).
1123
1155
  * @yields {EngineEvent}
1124
1156
  */
1125
- async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null } = {}) {
1157
+ async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, threadId = MAIN_THREAD_ID, drainPendingUserMessages = null } = {}) {
1126
1158
  if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
1127
1159
  yield {
1128
1160
  type: 'error',
@@ -1181,7 +1213,8 @@ export class Engine {
1181
1213
  const runSignal = abortCtrl.signal;
1182
1214
 
1183
1215
  try {
1184
- yield* this.#runQuery({ prompt: effectivePrompt, promptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, workDir, userAlreadyPersisted, getCurrentTodos, setCurrentTodos });
1216
+ this.#currentThreadId = threadId || MAIN_THREAD_ID;
1217
+ yield* this.#runQuery({ prompt: effectivePrompt, promptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, workDir, userAlreadyPersisted, getCurrentTodos, setCurrentTodos, threadId: this.#currentThreadId, drainPendingUserMessages });
1185
1218
  } finally {
1186
1219
  if (signal) {
1187
1220
  try { signal.removeEventListener('abort', onExternalAbort); } catch { /* ignore */ }
@@ -1190,6 +1223,8 @@ export class Engine {
1190
1223
  // and a subsequent query() starts with a clean slate.
1191
1224
  this.#currentAbortCtrl = null;
1192
1225
  this.#abortReason = null;
1226
+ this.#currentThreadId = MAIN_THREAD_ID;
1227
+ this.#pendingUserMessages.length = 0;
1193
1228
  }
1194
1229
  }
1195
1230
 
@@ -1199,7 +1234,7 @@ export class Engine {
1199
1234
  * in a try/finally without indenting the whole loop.
1200
1235
  * @private
1201
1236
  */
1202
- async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null }) {
1237
+ async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, threadId = MAIN_THREAD_ID, drainPendingUserMessages = null }) {
1203
1238
 
1204
1239
  // ─── Pre-query: FTS5 Memory Recall + AMS snapshot ─────
1205
1240
  // Memory has a SINGLE render outlet now (DESIGN-PROMPT §3 ③):
@@ -1223,7 +1258,7 @@ export class Engine {
1223
1258
  ? recallResult.entries.length
1224
1259
  : 0;
1225
1260
  if (recallEntryCount > 0) {
1226
- yield { type: 'recall', entryCount: recallEntryCount, cached: false };
1261
+ yield { type: 'recall', entryCount: recallEntryCount, cached: false, threadId };
1227
1262
  }
1228
1263
 
1229
1264
  // Layer-A summaries — same scopes AMS Resident will surface, loaded
@@ -1374,6 +1409,7 @@ export class Engine {
1374
1409
  yield {
1375
1410
  type: 'turn_open',
1376
1411
  turnId: queryTurnId,
1412
+ threadId,
1377
1413
  userPrompt: userQuestionPreview,
1378
1414
  vpId: queryVpId,
1379
1415
  groupId: groupId || null,
@@ -1424,8 +1460,8 @@ export class Engine {
1424
1460
  // the previous iteration) cleanly ends the loop instead of
1425
1461
  // launching another adapter stream.
1426
1462
  if (signal?.aborted) {
1427
- yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber };
1428
- yield { type: 'turn_end', turnNumber, stopReason: 'aborted' };
1463
+ yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber, threadId };
1464
+ yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId };
1429
1465
  break;
1430
1466
  }
1431
1467
 
@@ -1459,7 +1495,21 @@ export class Engine {
1459
1495
  // tools/registry.js can never disagree.
1460
1496
  const currentContextWindow = resolveContextWindow(currentModel, this.#config);
1461
1497
 
1462
- yield { type: 'turn_start', turnNumber };
1498
+ yield { type: 'turn_start', turnNumber, threadId };
1499
+
1500
+ const appendedBeforeStream = this.#drainPendingUserMessages(drainPendingUserMessages);
1501
+ if (appendedBeforeStream.length > 0) {
1502
+ for (const item of appendedBeforeStream) {
1503
+ conversationMessages.push({ role: 'user', content: item.content });
1504
+ yield {
1505
+ type: 'user_append',
1506
+ turnId: queryTurnId,
1507
+ loopNumber: turnNumber,
1508
+ threadId,
1509
+ preview: String(item.preview || '').slice(0, 200),
1510
+ };
1511
+ }
1512
+ }
1463
1513
 
1464
1514
  try {
1465
1515
  // task-327b: resolve effort per-turn so the long-loop auto-bump
@@ -1635,6 +1685,7 @@ export class Engine {
1635
1685
  yield {
1636
1686
  type: 'loop',
1637
1687
  turnId: queryTurnId,
1688
+ threadId,
1638
1689
  loopNumber: turnNumber,
1639
1690
  model: currentModel,
1640
1691
  systemPrompt,
@@ -1664,8 +1715,8 @@ export class Engine {
1664
1715
  || err?.name === 'LLMAbortError'
1665
1716
  || (signal?.aborted && /abort/i.test(err?.message || ''));
1666
1717
  if (isAbort || signal?.aborted) {
1667
- yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber };
1668
- yield { type: 'turn_end', turnNumber, stopReason: 'aborted' };
1718
+ yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber, threadId };
1719
+ yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId };
1669
1720
  break;
1670
1721
  }
1671
1722
 
@@ -1674,7 +1725,7 @@ export class Engine {
1674
1725
  const consolidated = await this.#maybeConsolidate();
1675
1726
  if (consolidated && consolidated.archivedCount > 0) {
1676
1727
  yield { type: 'consolidate', archivedCount: consolidated.archivedCount, extractedCount: consolidated.extractedCount };
1677
- yield { type: 'turn_end', turnNumber, stopReason: 'context_overflow_retry' };
1728
+ yield { type: 'turn_end', turnNumber, stopReason: 'context_overflow_retry', threadId };
1678
1729
  continue; // retry with fewer messages
1679
1730
  }
1680
1731
  }
@@ -1685,7 +1736,7 @@ export class Engine {
1685
1736
  (err.name === 'LLMRateLimitError' || err.name === 'LLMServerError')) {
1686
1737
  yield { type: 'fallback', from: currentModel, to: fallbackModel, reason: err.message };
1687
1738
  currentModel = fallbackModel;
1688
- yield { type: 'turn_end', turnNumber, stopReason: 'fallback_retry' };
1739
+ yield { type: 'turn_end', turnNumber, stopReason: 'fallback_retry', threadId };
1689
1740
  continue; // retry with fallback model
1690
1741
  }
1691
1742
 
@@ -1694,7 +1745,7 @@ export class Engine {
1694
1745
  error: err,
1695
1746
  retryable: err.name === 'LLMRateLimitError' || err.name === 'LLMServerError',
1696
1747
  };
1697
- yield { type: 'turn_end', turnNumber, stopReason: 'error' };
1748
+ yield { type: 'turn_end', turnNumber, stopReason: 'error', threadId };
1698
1749
  break;
1699
1750
  }
1700
1751
 
@@ -1788,13 +1839,33 @@ export class Engine {
1788
1839
  continueTurns++;
1789
1840
  // Append a "Continue" user message
1790
1841
  conversationMessages.push({ role: 'user', content: 'Continue' });
1791
- yield { type: 'turn_end', turnNumber, stopReason: 'max_tokens_continue' };
1842
+ yield { type: 'turn_end', turnNumber, stopReason: 'max_tokens_continue', threadId };
1792
1843
  continue; // loop back to call adapter again
1793
1844
  }
1794
1845
 
1846
+ // If new user input was appended while this loop was streaming and
1847
+ // there are no tools to force another loop, splice it now and continue
1848
+ // instead of ending the thread. This preserves token streaming and
1849
+ // still only mutates messages at a clean loop boundary.
1850
+ const appendedAfterAssistant = this.#drainPendingUserMessages(drainPendingUserMessages);
1851
+ if (appendedAfterAssistant.length > 0) {
1852
+ for (const item of appendedAfterAssistant) {
1853
+ conversationMessages.push({ role: 'user', content: item.content });
1854
+ yield {
1855
+ type: 'user_append',
1856
+ turnId: queryTurnId,
1857
+ loopNumber: turnNumber,
1858
+ threadId,
1859
+ preview: String(item.preview || '').slice(0, 200),
1860
+ };
1861
+ }
1862
+ yield { type: 'turn_end', turnNumber, stopReason: 'user_append_continue', threadId };
1863
+ continue;
1864
+ }
1865
+
1795
1866
  // If no tool calls, we're done
1796
1867
  if (stopReason !== 'tool_use' || toolCalls.length === 0) {
1797
- yield { type: 'turn_end', turnNumber, stopReason };
1868
+ yield { type: 'turn_end', turnNumber, stopReason, threadId };
1798
1869
 
1799
1870
  // ─── Post-query: StopHooks or Legacy ─────────────
1800
1871
  if (this.#config._readOnly) {
@@ -1825,6 +1896,7 @@ export class Engine {
1825
1896
  // Bug 6: tag persisted messages with the originating group so
1826
1897
  // history replay can re-stamp them on reload.
1827
1898
  groupId,
1899
+ threadId,
1828
1900
  // Multi-VP fan-out (history-dedup): skip the user-row append
1829
1901
  // in stop-hooks when the orchestrator already wrote it once
1830
1902
  // for this turn. The hook still persists assistant + tool
@@ -1859,6 +1931,7 @@ export class Engine {
1859
1931
  yield {
1860
1932
  type: 'memory_adjust',
1861
1933
  turnId: queryTurnId,
1934
+ threadId,
1862
1935
  groupKey: amsContext.groupKey,
1863
1936
  added: adjustResult.added,
1864
1937
  evicted: adjustResult.evicted,
@@ -2051,6 +2124,7 @@ export class Engine {
2051
2124
  yield {
2052
2125
  type: 'tool_exec',
2053
2126
  turnId: queryTurnId,
2127
+ threadId,
2054
2128
  loopNumber: turnNumber,
2055
2129
  callId: tc.id,
2056
2130
  name: tc.name,
@@ -2135,6 +2209,7 @@ export class Engine {
2135
2209
  turnNumber,
2136
2210
  stopReason: 'tool_handoff',
2137
2211
  detail: handoffDetail,
2212
+ threadId,
2138
2213
  };
2139
2214
  break;
2140
2215
  }
@@ -2178,6 +2253,7 @@ export class Engine {
2178
2253
  yield {
2179
2254
  type: 'reflection',
2180
2255
  turnId: queryTurnId,
2256
+ threadId,
2181
2257
  loopNumber: turnNumber,
2182
2258
  trigger: 't1',
2183
2259
  status: 'pending',
@@ -2212,6 +2288,7 @@ export class Engine {
2212
2288
  yield {
2213
2289
  type: 'reflection',
2214
2290
  turnId: queryTurnId,
2291
+ threadId,
2215
2292
  loopNumber: turnNumber,
2216
2293
  trigger: 't1',
2217
2294
  // PR-L bug fix: keep the same loopRange as the `pending` event
@@ -2229,6 +2306,7 @@ export class Engine {
2229
2306
  yield {
2230
2307
  type: 'reflection',
2231
2308
  turnId: queryTurnId,
2309
+ threadId,
2232
2310
  loopNumber: turnNumber,
2233
2311
  trigger: 't1',
2234
2312
  status: 'error',
@@ -2254,12 +2332,12 @@ export class Engine {
2254
2332
  // the typed `aborted` event + a final turn_end with stopReason
2255
2333
  // 'aborted' instead of looping back to a new adapter call.
2256
2334
  if (abortedDuringTools || signal?.aborted) {
2257
- yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber };
2258
- yield { type: 'turn_end', turnNumber, stopReason: 'aborted' };
2335
+ yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber, threadId };
2336
+ yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId };
2259
2337
  break;
2260
2338
  }
2261
2339
 
2262
- yield { type: 'turn_end', turnNumber, stopReason: 'tool_use' };
2340
+ yield { type: 'turn_end', turnNumber, stopReason: 'tool_use', threadId };
2263
2341
 
2264
2342
  // task-327b: count this as a tool-loop turn. Next iteration's
2265
2343
  // pickEffort() will see the bumped counter and upgrade to 'max'
@@ -2276,6 +2354,7 @@ export class Engine {
2276
2354
  yield {
2277
2355
  type: 'turn_close',
2278
2356
  turnId: queryTurnId,
2357
+ threadId,
2279
2358
  totalMs: Date.now() - queryStartedAt,
2280
2359
  totalTokens: cumulativeInputTokens + cumulativeOutputTokens,
2281
2360
  loopCount: turnNumber,
@@ -2399,7 +2478,22 @@ export class Engine {
2399
2478
  * @returns {string}
2400
2479
  */
2401
2480
  get currentThreadId() {
2402
- return MAIN_THREAD_ID;
2481
+ return this.#currentThreadId || MAIN_THREAD_ID;
2482
+ }
2483
+
2484
+ /**
2485
+ * Append a user message into the currently running query. The loop consumes
2486
+ * it only at adapter boundaries, never mid-token and never between an
2487
+ * assistant tool_use and its paired tool_result messages.
2488
+ * @param {string|Array} content
2489
+ * @returns {boolean}
2490
+ */
2491
+ appendUserMessage(content) {
2492
+ if (typeof content !== 'string' && !Array.isArray(content)) return false;
2493
+ if (typeof content === 'string' && !content.trim()) return false;
2494
+ const preview = typeof content === 'string' ? content : '[content blocks]';
2495
+ this.#pendingUserMessages.push({ content, preview });
2496
+ return true;
2403
2497
  }
2404
2498
 
2405
2499
  /** @returns {string|null} */
package/unify/session.js CHANGED
@@ -24,8 +24,9 @@ import { createFullRegistry } from './tools/index.js';
24
24
  import { Engine } from './engine.js';
25
25
  import { Compactor } from './compact/compactor.js';
26
26
  import { ToolUsageStats } from './stats/tool-usage.js';
27
- // H2.f.5: threads/, pipeline/dispatcher and input-queue retired. The
28
- // session now exposes a single Engine.
27
+ // H2.f.5 removed the old user-facing thread pipeline/dispatcher. The base
28
+ // session still exposes a single default Engine; PR #797 adds group VP thread
29
+ // engines in web-bridge runtime state, keyed below the session layer.
29
30
  //
30
31
  // GC.1 (final): the session opens a SegmentIndex (SQLite FTS5 over
31
32
  // memory.md) and passes it to the Engine. Engine.#recallMemory routes
@@ -250,7 +251,7 @@ export async function loadSession(options = {}) {
250
251
 
251
252
  // ─── 5a. (removed 2026-05-13) Feature store init — Feature system retired.
252
253
 
253
- // ─── 5b. (H2.f.5) thread store retired. Single conversation. ───
254
+ // ─── 5b. (H2.f.5) user-facing thread store retired. ───
254
255
 
255
256
  // ─── 5c. D1 first-boot seed (task-334m) ─────────────────
256
257
  // When no groups exist on disk AND we're not in read-only mode,
@@ -445,8 +446,9 @@ export async function loadSession(options = {}) {
445
446
  }).catch(() => { /* best-effort catch-up */ });
446
447
  }
447
448
 
448
- // H2.f.5: thread engine registry, input queue, and dispatcher retired.
449
- // The session exposes a single `engine`; web-bridge calls engine.query()
449
+ // H2.f.5 retired the old session-level thread engine registry, input queue,
450
+ // and dispatcher. The session exposes a default `engine`; PR #797 keeps
451
+ // group VP thread engines in web-bridge runtime state and calls engine.query()
450
452
  // directly. Memory recall happens via memory/preflow.js (pre-turn) and
451
453
  // memory/adjust.js (post-turn).
452
454
 
@@ -47,9 +47,9 @@ import routeForward from './route-forward.js';
47
47
  import todoWrite from './todo-write.js';
48
48
  import startPlan from './start-plan.js';
49
49
 
50
- // H2.f.4: thread tools (spawnThread/switchThread/listThreads/...) deleted.
51
- // The agent now runs in a single conversation; multi-thread orchestration
52
- // has been retired across the H2.f series.
50
+ // H2.f.4: user-facing thread tools (spawnThread/switchThread/listThreads/...)
51
+ // were deleted. PR #797 reintroduces runtime-owned VP thread routing below the
52
+ // tool layer; LLMs still do not manage threads via tools.
53
53
  //
54
54
  // Feature tools (FeatureCreate/Update/List/Get/Progress/Memory + Followup
55
55
  // + UpdatePlan + feature_summary_post) and the FeatureArc auto-creation
@@ -9,11 +9,11 @@
9
9
  * `tool_use` event — not the result — so this tool's persistence story
10
10
  * is "stamp into the LLM event stream and cache on ctx for replay."
11
11
  *
12
- * Per-VP isolation: each VP keeps its own current todo list. The
13
- * web-bridge injects `ctx.getCurrentTodos()` / `ctx.setCurrentTodos()`
14
- * pointing at a per-(groupId,vpId) slot so two VPs in the same group
15
- * can independently track their own multi-step tasks without
16
- * stepping on each other.
12
+ * Per-thread isolation: each running VP thread keeps its own current todo
13
+ * list. The web-bridge injects `ctx.getCurrentTodos()` /
14
+ * `ctx.setCurrentTodos()` pointing at a per-(groupId,vpId,threadId) slot so
15
+ * two concurrent threads for the same VP cannot overwrite each other's
16
+ * progress.
17
17
  *
18
18
  * Reference: plan §2 (2026-05-13 — Feature system retired, TodoWrite
19
19
  * added as the actual progress-tracking surface for the LLM).
@@ -0,0 +1,113 @@
1
+ const VALID_DECISIONS = new Set(['related', 'unrelated']);
2
+
3
+ export const THREAD_CLASSIFIER_SYSTEM_PROMPT = `You route a new user query for one VP into an existing running thread or a new thread.
4
+ Return JSON only:
5
+ {"decision":"related|unrelated","targetThreadId":"string|null","title":"short title","reason":"optional debug reason"}
6
+ Rules:
7
+ - If the query continues, clarifies, corrects, or adds details to an existing thread, choose related.
8
+ - If multiple threads match, choose the most relevant thread.
9
+ - If none match, choose unrelated.
10
+ - title must be 5-20 Chinese characters or 3-8 English words, matching the user language.
11
+ - Do not include markdown or extra prose.`;
12
+
13
+ export function fallbackTitle(query) {
14
+ const text = String(query || '').replace(/\s+/g, ' ').trim();
15
+ if (!text) return '新任务';
16
+ const withoutMentions = text.replace(/@\S+/g, '').trim() || text;
17
+ if (/[^\x00-\x7F]/.test(withoutMentions)) return withoutMentions.slice(0, 20);
18
+ return withoutMentions.split(' ').slice(0, 8).join(' ').slice(0, 80);
19
+ }
20
+
21
+ function stripJsonFence(text) {
22
+ const raw = String(text || '').trim();
23
+ if (!raw.startsWith('```')) return raw;
24
+ return raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim();
25
+ }
26
+
27
+ export function parseThreadClassification(text, runningThreads = [], query = '') {
28
+ let parsed;
29
+ try {
30
+ parsed = JSON.parse(stripJsonFence(text));
31
+ } catch {
32
+ return fallbackClassification(runningThreads, query, 'invalid_json');
33
+ }
34
+ return validateThreadClassification(parsed, runningThreads, query);
35
+ }
36
+
37
+ export function validateThreadClassification(value, runningThreads = [], query = '') {
38
+ const known = new Set((runningThreads || []).map(t => t && t.threadId).filter(Boolean));
39
+ const decision = VALID_DECISIONS.has(value && value.decision) ? value.decision : null;
40
+ const title = typeof value?.title === 'string' && value.title.trim()
41
+ ? value.title.trim().slice(0, 80)
42
+ : fallbackTitle(query);
43
+ const reason = typeof value?.reason === 'string' ? value.reason.slice(0, 500) : '';
44
+
45
+ if (decision === 'related') {
46
+ const targetThreadId = typeof value?.targetThreadId === 'string' ? value.targetThreadId : '';
47
+ if (targetThreadId && known.has(targetThreadId)) {
48
+ return { decision: 'related', targetThreadId, title, reason };
49
+ }
50
+ return { decision: 'unrelated', targetThreadId: null, title, reason: reason || 'invalid_target_thread' };
51
+ }
52
+ if (decision === 'unrelated') {
53
+ return { decision: 'unrelated', targetThreadId: null, title, reason };
54
+ }
55
+ return fallbackClassification(runningThreads, query, 'invalid_decision');
56
+ }
57
+
58
+ export function fallbackClassification(runningThreads = [], query = '', reason = 'fallback') {
59
+ const live = (runningThreads || []).filter(t => t && t.threadId);
60
+ if (live.length === 1) {
61
+ return {
62
+ decision: 'related',
63
+ targetThreadId: live[0].threadId,
64
+ title: live[0].title || fallbackTitle(query),
65
+ reason,
66
+ };
67
+ }
68
+ return {
69
+ decision: 'unrelated',
70
+ targetThreadId: null,
71
+ title: fallbackTitle(query),
72
+ reason,
73
+ };
74
+ }
75
+
76
+ export function buildThreadClassificationPrompt({ vp = {}, runningThreads = [], newQuery = '' } = {}) {
77
+ const payload = {
78
+ vp: {
79
+ vpId: vp.vpId || '',
80
+ displayName: vp.displayName || vp.displayNameZh || vp.vpId || '',
81
+ role: vp.role || vp.roleZh || '',
82
+ persona: String(vp.persona || '').slice(0, 600),
83
+ },
84
+ runningThreads: (runningThreads || []).map(t => ({
85
+ threadId: t.threadId,
86
+ title: t.title || '',
87
+ status: t.status || '',
88
+ updatedAt: t.updatedAt || null,
89
+ recentMessages: Array.isArray(t.recentMessages) ? t.recentMessages.slice(-6) : [],
90
+ summary: t.summary || '',
91
+ })),
92
+ newQuery: String(newQuery || '').slice(0, 4000),
93
+ };
94
+ return JSON.stringify(payload, null, 2);
95
+ }
96
+
97
+ export async function classifyThread({ adapter, model, vp, runningThreads, newQuery, signal } = {}) {
98
+ if (!adapter || typeof adapter.call !== 'function') {
99
+ return fallbackClassification(runningThreads, newQuery, 'no_adapter');
100
+ }
101
+ try {
102
+ const res = await adapter.call({
103
+ model,
104
+ system: THREAD_CLASSIFIER_SYSTEM_PROMPT,
105
+ messages: [{ role: 'user', content: buildThreadClassificationPrompt({ vp, runningThreads, newQuery }) }],
106
+ maxTokens: 256,
107
+ signal,
108
+ });
109
+ return parseThreadClassification(res && res.text, runningThreads, newQuery);
110
+ } catch (err) {
111
+ return fallbackClassification(runningThreads, newQuery, `classifier_error:${err?.message || err}`);
112
+ }
113
+ }