@yeaft/webchat-agent 0.1.792 → 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 +1 -1
- package/unify/engine.js +118 -24
- package/unify/session.js +7 -5
- package/unify/tools/index.js +3 -3
- package/unify/tools/todo-write.js +5 -5
- package/unify/vp/seed-defaults.js +28 -0
- package/unify/vp/stock-ids.js +53 -0
- package/unify/vp/thread-classifier.js +113 -0
- package/unify/vp/vp-bridge.js +10 -2
- package/unify/vp/vp-crud.js +17 -7
- package/unify/vp-status-broker.js +120 -165
- package/unify/web-bridge.js +480 -296
package/package.json
CHANGED
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
|
-
//
|
|
35
|
-
//
|
|
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
|
-
//
|
|
954
|
-
//
|
|
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
|
-
|
|
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
|
|
28
|
-
// session
|
|
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.
|
|
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
|
|
449
|
-
// The session exposes a
|
|
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
|
|
package/unify/tools/index.js
CHANGED
|
@@ -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/...)
|
|
51
|
-
//
|
|
52
|
-
//
|
|
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-
|
|
13
|
-
* web-bridge injects `ctx.getCurrentTodos()` /
|
|
14
|
-
* pointing at a per-(groupId,vpId) slot so
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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).
|
|
@@ -31,6 +31,7 @@ import { existsSync, mkdirSync, readdirSync, statSync } from 'fs';
|
|
|
31
31
|
import { join } from 'path';
|
|
32
32
|
import { createVp, VpCrudError } from './vp-crud.js';
|
|
33
33
|
import { DEFAULT_VP_LIB_DIR } from './vp-store.js';
|
|
34
|
+
import { STOCK_VP_IDS } from './stock-ids.js';
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
37
|
* The 33 default VPs. Each entry is a valid `createVp` payload.
|
|
@@ -855,6 +856,33 @@ Bad for: requests that require pretending to be a licensed professional, bypassi
|
|
|
855
856
|
},
|
|
856
857
|
]);
|
|
857
858
|
|
|
859
|
+
/**
|
|
860
|
+
* Self-check: every seed persona's vpId must appear in STOCK_VP_IDS, and
|
|
861
|
+
* vice versa. The two lists live in separate modules to break a circular
|
|
862
|
+
* import (see stock-ids.js header), so the only thing keeping them in
|
|
863
|
+
* sync is this load-time assertion. If you add a new seed VP and forget
|
|
864
|
+
* to add its id to stock-ids.js#STOCK_VP_ID_LIST (or vice versa), the
|
|
865
|
+
* agent will refuse to start with a clear error.
|
|
866
|
+
*/
|
|
867
|
+
const _seedIds = new Set(DEFAULT_VPS.map(v => v.vpId));
|
|
868
|
+
{
|
|
869
|
+
const missingInStockIds = [];
|
|
870
|
+
for (const id of _seedIds) {
|
|
871
|
+
if (!STOCK_VP_IDS.has(id)) missingInStockIds.push(id);
|
|
872
|
+
}
|
|
873
|
+
const missingInSeeds = [];
|
|
874
|
+
for (const id of STOCK_VP_IDS) {
|
|
875
|
+
if (!_seedIds.has(id)) missingInSeeds.push(id);
|
|
876
|
+
}
|
|
877
|
+
if (missingInStockIds.length || missingInSeeds.length) {
|
|
878
|
+
throw new Error(
|
|
879
|
+
'[seed-defaults] DEFAULT_VPS / STOCK_VP_IDS mismatch — '
|
|
880
|
+
+ `add to stock-ids.js: [${missingInStockIds.join(', ')}]; `
|
|
881
|
+
+ `add to DEFAULT_VPS: [${missingInSeeds.join(', ')}]`,
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
858
886
|
/**
|
|
859
887
|
* True iff `libDir` exists and contains at least one subdirectory that
|
|
860
888
|
* looks like a VP entry (has a `role.md` file). A stray empty directory
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stock-ids.js — single source of truth for "is this vpId a stock seed VP?".
|
|
3
|
+
*
|
|
4
|
+
* Owns the canonical list of seed vpIds and exposes `STOCK_VP_IDS` (a Set
|
|
5
|
+
* for O(1) lookup) plus `isStockVpId(vpId)`.
|
|
6
|
+
*
|
|
7
|
+
* Why this lives in its own tiny module instead of in seed-defaults.js:
|
|
8
|
+
* `seed-defaults.js` imports `createVp / VpCrudError` from `vp-crud.js`,
|
|
9
|
+
* so if `vp-crud.js` were to import `STOCK_VP_IDS` from `seed-defaults`
|
|
10
|
+
* directly, the module graph would be circular (vp-crud → seed-defaults
|
|
11
|
+
* → vp-crud). The cycle "works" today only because every consumer reads
|
|
12
|
+
* STOCK_VP_IDS inside a function body (live binding), but a future
|
|
13
|
+
* refactor that moves the check to module top level — say, to validate
|
|
14
|
+
* input on import — would crash with `STOCK_VP_IDS is undefined`. Moving
|
|
15
|
+
* the Set into a leaf module with zero inbound deps from elsewhere in
|
|
16
|
+
* the package breaks the cycle for good.
|
|
17
|
+
*
|
|
18
|
+
* Authoritative-vs-derived: this file is the SOURCE OF TRUTH for stock
|
|
19
|
+
* ids. seed-defaults.js asserts at module load that every entry in
|
|
20
|
+
* DEFAULT_VPS appears here, and vice versa — see the self-check at the
|
|
21
|
+
* bottom of seed-defaults.js. So adding a new seed VP only requires
|
|
22
|
+
* adding both the persona object *and* its id here (two-file change,
|
|
23
|
+
* caught by the assertion if you forget either).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const STOCK_VP_ID_LIST = Object.freeze([
|
|
27
|
+
// engineering
|
|
28
|
+
'steve', 'linus', 'martin', 'dieter', 'ada', 'grace', 'alice', 'ken',
|
|
29
|
+
'margaret', 'shannon', 'alan', 'norman',
|
|
30
|
+
// philosophy / psychology
|
|
31
|
+
'kongzi', 'socrates', 'nietzsche', 'kahneman', 'jung',
|
|
32
|
+
// strategy / business
|
|
33
|
+
'sunzi', 'clausewitz', 'simaqian', 'harari',
|
|
34
|
+
'buffett', 'munger', 'dalio', 'bezos', 'drucker',
|
|
35
|
+
// arts / culture
|
|
36
|
+
'luxun', 'sudongpo', 'borges', 'einstein', 'kubrick', 'miyazaki',
|
|
37
|
+
// assistant
|
|
38
|
+
'omni',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/** @type {ReadonlySet<string>} */
|
|
42
|
+
export const STOCK_VP_IDS = new Set(STOCK_VP_ID_LIST);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* True iff `vpId` is a stock seed VP that ships with the agent.
|
|
46
|
+
* Pure function of the string — undefined / non-string input returns false.
|
|
47
|
+
*
|
|
48
|
+
* @param {unknown} vpId
|
|
49
|
+
* @returns {boolean}
|
|
50
|
+
*/
|
|
51
|
+
export function isStockVpId(vpId) {
|
|
52
|
+
return typeof vpId === 'string' && STOCK_VP_IDS.has(vpId);
|
|
53
|
+
}
|
|
@@ -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
|
+
}
|
package/unify/vp/vp-bridge.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import { defaultRegistry } from './registry.js';
|
|
25
25
|
import { VpLoader } from './vp-loader.js';
|
|
26
|
+
import { STOCK_VP_IDS } from './stock-ids.js';
|
|
26
27
|
|
|
27
28
|
/** Process-singleton VpLoader; lazily started on first subscribe. */
|
|
28
29
|
let _loaderStarted = false;
|
|
@@ -175,8 +176,8 @@ function ensureLoader(registry = defaultRegistry) {
|
|
|
175
176
|
* Serialise a VP (entity layer shape) to the wire-format the web layer
|
|
176
177
|
* expects (spec §2.1). Pure; no IO.
|
|
177
178
|
*
|
|
178
|
-
* @param {{id:string,name:string,role:string,traits?:string[],modelHint?:string,personaHash?:string}} vp
|
|
179
|
-
* @returns {{vpId:string,displayName:string,subtitle:string,role:string,traits:string[],modelHint:?string,personaHash:?string}}
|
|
179
|
+
* @param {{id:string,name:string,role:string,nameZh?:string,aliases?:string[],traits?:string[],modelHint?:string,personaHash?:string}} vp
|
|
180
|
+
* @returns {{vpId:string,displayName:string,displayNameZh:string,aliases:string[],subtitle:string,role:string,traits:string[],modelHint:?string,personaHash:?string,isStock:boolean}}
|
|
180
181
|
*/
|
|
181
182
|
export function serializeVpForWire(vp) {
|
|
182
183
|
return {
|
|
@@ -191,6 +192,13 @@ export function serializeVpForWire(vp) {
|
|
|
191
192
|
traits: Array.isArray(vp.traits) ? vp.traits.slice() : [],
|
|
192
193
|
modelHint: vp.modelHint ?? null,
|
|
193
194
|
personaHash: vp.personaHash ?? null,
|
|
195
|
+
// task-vp-customize: mark seed VPs so the frontend can disable
|
|
196
|
+
// Edit/Delete and surface a "Stock" badge. Pure id check — see
|
|
197
|
+
// stock-ids.js#STOCK_VP_IDS for the contract. `Set.has(undefined)`
|
|
198
|
+
// is fine, but we still coerce to plain boolean so the wire field
|
|
199
|
+
// is strictly `true | false` (never `undefined`) and downstream
|
|
200
|
+
// `!!` reads can collapse cleanly.
|
|
201
|
+
isStock: STOCK_VP_IDS.has(vp.id) === true,
|
|
194
202
|
};
|
|
195
203
|
}
|
|
196
204
|
|