@yeaft/webchat-agent 0.1.851 → 0.1.853

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.851",
3
+ "version": "0.1.853",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -22,7 +22,7 @@ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rename
22
22
  import { join, basename } from 'path';
23
23
  import { isPermissionError } from '../init.js';
24
24
  import { pairSanitize } from '../pair-sanitize.js';
25
- import { sliceLastNTurns } from '../turn-utils.js';
25
+ import { indexOfNthTurnFromEnd, sliceLastNTurns } from '../turn-utils.js';
26
26
 
27
27
  /**
28
28
  * Default cold-start "recent window" size, expressed in TURNS (not raw
@@ -450,20 +450,22 @@ export class ConversationStore {
450
450
  }
451
451
 
452
452
  /**
453
- * Update the compact summary (cumulative).
453
+ * Rewrite the compact summary in place.
454
454
  *
455
- * @param {string} summary new summary to append
455
+ * Compact's semantics are "the running summary of everything older than
456
+ * the hot window". Each compact pass already received the previous
457
+ * summary text as input and produced a *new* cumulative summary — so
458
+ * we overwrite, we never append. Appending was the original behaviour
459
+ * (kept around as a diary), but the engine reads the whole file back
460
+ * into `<conversation_summary>` on every turn, so appending grows the
461
+ * per-turn prompt without bound until it defeats compaction itself.
462
+ *
463
+ * @param {string} summary — the new, complete summary to persist
456
464
  */
457
- updateCompactSummary(summary) {
458
- let existing = '';
459
- if (existsSync(this.#compactPath)) {
460
- existing = readFileSync(this.#compactPath, 'utf8');
461
- }
462
-
463
- const date = new Date().toISOString().split('T')[0];
464
- const entry = `\n## ${date}\n\n${summary}\n`;
465
+ replaceCompactSummary(summary) {
466
+ if (typeof summary !== 'string' || !summary) return;
465
467
  try {
466
- writeFileSync(this.#compactPath, existing + entry, { encoding: 'utf8', mode: 0o644 });
468
+ writeFileSync(this.#compactPath, summary, { encoding: 'utf8', mode: 0o644 });
467
469
  } catch (err) {
468
470
  if (isPermissionError(err)) {
469
471
  if (!_permissionWarned) {
@@ -539,26 +541,19 @@ export class ConversationStore {
539
541
  }
540
542
 
541
543
  /**
542
- * Append a per-(groupId, vpId) compact summary entry. Same append-only
543
- * "## YYYY-MM-DD ..." structure as the legacy `updateCompactSummary`,
544
- * but isolated to one file per (group, vp).
544
+ * Rewrite a per-(groupId, vpId) compact summary in place. See
545
+ * `replaceCompactSummary` for the rationale same reason, scoped file.
545
546
  *
546
547
  * @param {string} groupId
547
548
  * @param {string} vpId
548
549
  * @param {string} summary
549
550
  */
550
- updateCompactSummaryFor(groupId, vpId, summary) {
551
+ replaceCompactSummaryFor(groupId, vpId, summary) {
552
+ if (typeof summary !== 'string' || !summary) return;
551
553
  const path = this.#scopedCompactPath(groupId, vpId);
552
554
  if (!path) return;
553
- let existing = '';
554
- if (existsSync(path)) {
555
- try { existing = readFileSync(path, 'utf8'); }
556
- catch { existing = ''; }
557
- }
558
- const date = new Date().toISOString().split('T')[0];
559
- const entry = `\n## ${date}\n\n${summary}\n`;
560
555
  try {
561
- writeFileSync(path, existing + entry, { encoding: 'utf8', mode: 0o644 });
556
+ writeFileSync(path, summary, { encoding: 'utf8', mode: 0o644 });
562
557
  } catch (err) {
563
558
  if (isPermissionError(err)) {
564
559
  if (!_permissionWarned) {
@@ -870,6 +865,43 @@ export class ConversationStore {
870
865
  return { messages: sliced, oldestSeq, hasMore };
871
866
  }
872
867
 
868
+ /**
869
+ * Visible UI pagination read for one group. Unlike `loadOlderByGroup`, this
870
+ * projects out internal/reflection/system rows BEFORE applying the turn
871
+ * window, so a dense run of hidden metadata cannot force the first screen to
872
+ * scan and materialize the group's entire history in the web bridge.
873
+ *
874
+ * @param {string} groupId
875
+ * @param {number|null} beforeSeq — exclusive upper bound, or null for newest
876
+ * @param {number} [turnsLimit=DEFAULT_RECENT_TURNS]
877
+ * @returns {{ messages: object[], oldestSeq: number|null, hasMore: boolean }}
878
+ */
879
+ loadVisibleByGroup(groupId, beforeSeq, turnsLimit = DEFAULT_RECENT_TURNS) {
880
+ if (!groupId || !(turnsLimit > 0)) return { messages: [], oldestSeq: null, hasMore: false };
881
+
882
+ const cutoff = Number.isFinite(beforeSeq) ? beforeSeq : Infinity;
883
+ const hot = this.#loadVisibleFromDirByGroup(this.#msgDir, groupId, cutoff);
884
+ const cold = this.#loadVisibleFromDirByGroup(this.#coldDir, groupId, cutoff);
885
+ const visible = [...cold, ...hot];
886
+ if (visible.length === 0) return { messages: [], oldestSeq: null, hasMore: false };
887
+
888
+ const startIdx = indexOfNthTurnFromEnd(visible, turnsLimit);
889
+ const start = startIdx === -1 ? 0 : startIdx;
890
+ const messages = pairSanitize(visible.slice(start));
891
+ const oldestSeq = messages.length ? parseSeqFromId(messages[0].id) : null;
892
+ const firstVisibleSeq = parseSeqFromId(visible[0].id);
893
+ const hasMore = messages.length > 0
894
+ && Number.isFinite(oldestSeq)
895
+ && Number.isFinite(firstVisibleSeq)
896
+ && oldestSeq > firstVisibleSeq;
897
+
898
+ return {
899
+ messages,
900
+ oldestSeq: Number.isFinite(oldestSeq) ? oldestSeq : null,
901
+ hasMore,
902
+ };
903
+ }
904
+
873
905
  /**
874
906
  * Count hot messages.
875
907
  *
@@ -1319,6 +1351,32 @@ export class ConversationStore {
1319
1351
  return messages;
1320
1352
  }
1321
1353
 
1354
+ #loadVisibleFromDirByGroup(dir, groupId, beforeSeq) {
1355
+ if (!existsSync(dir)) return [];
1356
+
1357
+ const files = readdirSync(dir)
1358
+ .filter(f => f.endsWith('.md'))
1359
+ .sort();
1360
+
1361
+ const out = [];
1362
+ for (const file of files) {
1363
+ const seq = parseSeqFromId(basename(file, '.md'));
1364
+ if (!Number.isFinite(seq) || seq >= beforeSeq) continue;
1365
+
1366
+ const raw = readFileSync(join(dir, file), 'utf8');
1367
+ if (!raw.includes(`groupId: ${groupId}`)) continue;
1368
+ if (!raw.includes('role: user') && !raw.includes('role: assistant')) continue;
1369
+
1370
+ const parsed = parseMessage(raw);
1371
+ if (!parsed || parsed.groupId !== groupId) continue;
1372
+ if (parsed._reflection || parsed.internal || parsed.systemOnly || parsed.systemOnlyMessage) continue;
1373
+ if (parsed.role !== 'user' && parsed.role !== 'assistant') continue;
1374
+ out.push(parsed);
1375
+ }
1376
+
1377
+ return out;
1378
+ }
1379
+
1322
1380
  /**
1323
1381
  * Determine the next sequence number by scanning existing files.
1324
1382
  * @returns {number}
package/unify/engine.js CHANGED
@@ -1149,18 +1149,32 @@ export class Engine {
1149
1149
  // a jarring locale flip mid-context.
1150
1150
  const isZh = String(this.#config.language || '').toLowerCase().startsWith('zh');
1151
1151
  const summariserSystem = isZh
1152
- ? '你是对话摘要器。请用中文写出 2–3 段简明摘要,保留决策、事实与上下文。'
1153
- : 'You are a conversation summarizer. Summarize concisely in 2–3 paragraphs, preserving decisions, facts, and context.';
1152
+ ? '你是对话摘要器。下面包含「先前累计摘要」(可能为空)与「新待压缩对话」。请融合两者,输出一份「重写后的累计摘要」——不要分段罗列日期、不要保留 "## 2026-..." 等历史分节,直接产出一份连贯、可被下一轮直接重新注入 prompt 的摘要。保留关键决策、事实、上下文与人物意图。'
1153
+ : 'You are a conversation summarizer. The input contains a "previous cumulative summary" (may be empty) plus a "new conversation to absorb". Merge them into ONE rewritten cumulative summary — do NOT keep dated section headers or any historical log structure. Output a single coherent summary suitable to be re-injected into the next turn\'s prompt as-is. Preserve key decisions, facts, context, and intent.';
1154
1154
  const summariserPromptPrefix = isZh ? '请概括:\n\n' : 'Summarize:\n\n';
1155
1155
 
1156
1156
  const hooks = {
1157
1157
  summarise: async () => {
1158
1158
  try {
1159
+ // Read prior summary at call time, not at orchestrator setup,
1160
+ // so the merge always sees the freshest on-disk state even if
1161
+ // future orchestrator changes invoke summarise more than once.
1162
+ const priorSummary = this.#getCompactSummary() || '';
1163
+ const priorBlock = priorSummary
1164
+ ? (isZh
1165
+ ? `【先前累计摘要】\n${priorSummary}\n\n【新待压缩对话】\n`
1166
+ : `[Previous cumulative summary]\n${priorSummary}\n\n[New conversation to absorb]\n`)
1167
+ : '';
1159
1168
  const result = await adapter.call({
1160
1169
  model: fastConfig.model,
1161
1170
  system: summariserSystem,
1162
- messages: [{ role: 'user', content: `${summariserPromptPrefix}${toArchive.map(m => `[${m.role}] ${(m.content || '').slice(0, 500)}`).join('\n\n')}` }],
1163
- maxTokens: 1024,
1171
+ messages: [{ role: 'user', content: `${summariserPromptPrefix}${priorBlock}${toArchive.map(m => `[${m.role}] ${(m.content || '').slice(0, 500)}`).join('\n\n')}` }],
1172
+ // 10k output budget: the running summary is the engine's
1173
+ // long-term memory of cold turns, so it deserves room to
1174
+ // actually preserve detail. We rewrite-in-place each round,
1175
+ // so size stays bounded by maxTokens regardless of how many
1176
+ // compact passes have run.
1177
+ maxTokens: 10240,
1164
1178
  });
1165
1179
  return (result.text || '').trim();
1166
1180
  } catch {
@@ -1204,10 +1218,10 @@ export class Engine {
1204
1218
  conversationStore.moveToColdBatch(archiveIds);
1205
1219
  }
1206
1220
  if (out.compactSummary) {
1207
- if (scoped && typeof conversationStore.updateCompactSummaryFor === 'function') {
1208
- conversationStore.updateCompactSummaryFor(this.#groupId, this.#vpId, out.compactSummary);
1221
+ if (scoped && typeof conversationStore.replaceCompactSummaryFor === 'function') {
1222
+ conversationStore.replaceCompactSummaryFor(this.#groupId, this.#vpId, out.compactSummary);
1209
1223
  } else {
1210
- conversationStore.updateCompactSummary(out.compactSummary);
1224
+ conversationStore.replaceCompactSummary(out.compactSummary);
1211
1225
  }
1212
1226
  }
1213
1227
  // Index update only makes sense for the legacy path that actually
@@ -602,10 +602,16 @@ function loadVisibleGroupHistoryPage(store, groupId, limit, beforeSeq = null) {
602
602
 
603
603
  let rows = [];
604
604
  try {
605
- if (typeof store.loadOlderByGroup === 'function') {
606
- // Use an unbounded raw prefix, then project/slice visible rows below.
607
- // This preserves loadOlderByGroup's hot+cold scan without letting raw
608
- // reflection/internal rows consume the UI-visible page window.
605
+ if (typeof store.loadVisibleByGroup === 'function') {
606
+ const page = store.loadVisibleByGroup(groupId, beforeSeq, limit);
607
+ return {
608
+ messages: (page.messages || []).map(projectPersistedToVisibleHistoryEntry).filter(Boolean),
609
+ oldestSeq: (typeof page.oldestSeq === 'number') ? page.oldestSeq : null,
610
+ hasMore: !!page.hasMore,
611
+ };
612
+ } else if (typeof store.loadOlderByGroup === 'function') {
613
+ // Compatibility fallback for older test doubles: use an unbounded raw
614
+ // prefix, then project/slice visible rows below.
609
615
  rows = store.loadOlderByGroup(groupId, beforeSeq, Infinity).messages || [];
610
616
  } else if (Number.isFinite(beforeSeq)) {
611
617
  const all = typeof store.loadAllByGroup === 'function'
@@ -3582,11 +3588,10 @@ export async function handleUnifyLoadHistory(msg) {
3582
3588
  }
3583
3589
 
3584
3590
  // `msg.limit` is the replay-scrollback request from the frontend (UI
3585
- // history pane, not engine context). Semantics changed (2026-05-01):
3586
- // now expressed in TURNS. The previous default (50 messages) maps to
3587
- // ~20–25 turns; in the turn-count world 50 turns of UI scrollback is
3588
- // still cheap and matches what the frontend already passes through.
3589
- const limit = (typeof msg.limit === 'number') ? msg.limit : 50;
3591
+ // history pane, not engine context). Keep the bootstrap window small so
3592
+ // opening a group can paint the latest messages quickly; older rows are
3593
+ // paged via `unify_load_more_history` when the user scrolls upward.
3594
+ const limit = (typeof msg.limit === 'number') ? msg.limit : 10;
3590
3595
  const visiblePage = groupId
3591
3596
  ? loadVisibleGroupHistoryPage(session.conversationStore, groupId, limit)
3592
3597
  : { messages: limit > 0 ? pickRecent(session.conversationStore, limit) : [], oldestSeq: null, hasMore: false };
@@ -3691,7 +3696,7 @@ export async function handleUnifyLoadMoreHistory(msg) {
3691
3696
  }
3692
3697
 
3693
3698
  const beforeSeq = (typeof msg.beforeSeq === 'number') ? msg.beforeSeq : null;
3694
- const turns = (typeof msg.turns === 'number' && msg.turns > 0) ? msg.turns : 20;
3699
+ const turns = (typeof msg.turns === 'number' && msg.turns > 0) ? msg.turns : 10;
3695
3700
 
3696
3701
  let result;
3697
3702
  try {