@yeaft/webchat-agent 0.1.1090 → 0.1.1092
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/yeaft/conversation/persist.js +104 -43
- package/yeaft/web-bridge.js +120 -4
package/package.json
CHANGED
|
@@ -27,7 +27,7 @@ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rename
|
|
|
27
27
|
import { join, basename } from 'path';
|
|
28
28
|
import { isPermissionError } from '../init.js';
|
|
29
29
|
import { pairSanitize } from '../pair-sanitize.js';
|
|
30
|
-
import { countTurns, indexOfNthTurnFromEnd, sliceLastNTurns } from '../turn-utils.js';
|
|
30
|
+
import { countTurns, indexOfNthTurnFromEnd, sliceLastNTurns, stripVpMentionPrefix } from '../turn-utils.js';
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* Default cold-start "recent window" size, expressed in TURNS (not raw
|
|
@@ -167,6 +167,17 @@ function compareMessagesBySeq(a, b) {
|
|
|
167
167
|
return String(a?.time || '').localeCompare(String(b?.time || ''));
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
function canonicalUserTurnContent(content) {
|
|
171
|
+
if (typeof content === 'string') return stripVpMentionPrefix(content);
|
|
172
|
+
if (!Array.isArray(content)) return null;
|
|
173
|
+
const text = content
|
|
174
|
+
.filter(part => part && typeof part === 'object' && part.type === 'text')
|
|
175
|
+
.map(part => typeof part.text === 'string' ? part.text : '')
|
|
176
|
+
.join('\n')
|
|
177
|
+
.trim();
|
|
178
|
+
return text ? stripVpMentionPrefix(text) : null;
|
|
179
|
+
}
|
|
180
|
+
|
|
170
181
|
// ─── Frontmatter helpers ─────────────────────────────────────
|
|
171
182
|
|
|
172
183
|
/**
|
|
@@ -1057,19 +1068,24 @@ export class ConversationStore {
|
|
|
1057
1068
|
if (!sessionId || !(turnsLimit > 0)) return { messages: [], oldestSeq: null, hasMore: false };
|
|
1058
1069
|
|
|
1059
1070
|
const cutoff = Number.isFinite(beforeSeq) ? beforeSeq : Infinity;
|
|
1060
|
-
const
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1071
|
+
const page = this.#loadVisibleWindowBySession(
|
|
1072
|
+
[
|
|
1073
|
+
...this.#sessionMessageDirs('messages', sessionId),
|
|
1074
|
+
this.#legacyMsgDir,
|
|
1075
|
+
...this.#sessionMessageDirs('cold', sessionId),
|
|
1076
|
+
this.#legacyColdDir,
|
|
1077
|
+
],
|
|
1078
|
+
sessionId,
|
|
1079
|
+
cutoff,
|
|
1080
|
+
turnsLimit
|
|
1081
|
+
);
|
|
1064
1082
|
|
|
1065
|
-
const startIdx = indexOfNthTurnFromEnd(visible, turnsLimit);
|
|
1066
|
-
const start = startIdx === -1 ? 0 : startIdx;
|
|
1067
1083
|
// Visible history is for UI replay, not LLM context. The visible loader
|
|
1068
1084
|
// already excludes tool-result rows, so running pairSanitize here can
|
|
1069
1085
|
// incorrectly treat tool-using assistant replies as orphaned tool arcs and
|
|
1070
1086
|
// drop/trim VP messages. Strip tool-call metadata instead and keep the
|
|
1071
1087
|
// user-visible assistant text for the conversation pane.
|
|
1072
|
-
const messages =
|
|
1088
|
+
const messages = page.messages.map(m => {
|
|
1073
1089
|
if (m && m.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
|
|
1074
1090
|
const { toolCalls, ...rest } = m;
|
|
1075
1091
|
return rest;
|
|
@@ -1077,16 +1093,11 @@ export class ConversationStore {
|
|
|
1077
1093
|
return m;
|
|
1078
1094
|
});
|
|
1079
1095
|
const oldestSeq = messages.length ? parseSeqFromId(messages[0].id) : null;
|
|
1080
|
-
const firstVisibleSeq = parseSeqFromId(visible[0].id);
|
|
1081
|
-
const hasMore = messages.length > 0
|
|
1082
|
-
&& Number.isFinite(oldestSeq)
|
|
1083
|
-
&& Number.isFinite(firstVisibleSeq)
|
|
1084
|
-
&& oldestSeq > firstVisibleSeq;
|
|
1085
1096
|
|
|
1086
1097
|
return {
|
|
1087
1098
|
messages,
|
|
1088
1099
|
oldestSeq: Number.isFinite(oldestSeq) ? oldestSeq : null,
|
|
1089
|
-
hasMore,
|
|
1100
|
+
hasMore: page.hasMore,
|
|
1090
1101
|
};
|
|
1091
1102
|
}
|
|
1092
1103
|
|
|
@@ -1733,9 +1744,85 @@ export class ConversationStore {
|
|
|
1733
1744
|
return total;
|
|
1734
1745
|
}
|
|
1735
1746
|
|
|
1736
|
-
#
|
|
1737
|
-
|
|
1738
|
-
|
|
1747
|
+
#loadVisibleWindowBySession(dirs, sessionId, beforeSeq, turnsLimit) {
|
|
1748
|
+
const candidates = [];
|
|
1749
|
+
const seen = new Set();
|
|
1750
|
+
for (const dir of dirs) {
|
|
1751
|
+
if (!existsSync(dir)) continue;
|
|
1752
|
+
let files = [];
|
|
1753
|
+
try {
|
|
1754
|
+
files = readdirSync(dir);
|
|
1755
|
+
} catch (err) {
|
|
1756
|
+
if (!isPermissionError(err)) throw err;
|
|
1757
|
+
continue;
|
|
1758
|
+
}
|
|
1759
|
+
for (const file of files) {
|
|
1760
|
+
if (!file.endsWith('.md')) continue;
|
|
1761
|
+
const seq = parseSeqFromId(basename(file, '.md'));
|
|
1762
|
+
if (!Number.isFinite(seq) || seq >= beforeSeq) continue;
|
|
1763
|
+
const path = join(dir, file);
|
|
1764
|
+
if (seen.has(path)) continue;
|
|
1765
|
+
seen.add(path);
|
|
1766
|
+
candidates.push({ seq, path });
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
candidates.sort((a, b) => b.seq - a.seq);
|
|
1771
|
+
|
|
1772
|
+
const selected = [];
|
|
1773
|
+
const pendingBoundaryTail = [];
|
|
1774
|
+
let turnsSeen = 0;
|
|
1775
|
+
let openCanonical = null;
|
|
1776
|
+
let boundaryCanonical = null;
|
|
1777
|
+
let hasMore = false;
|
|
1778
|
+
|
|
1779
|
+
for (const candidate of candidates) {
|
|
1780
|
+
let raw = '';
|
|
1781
|
+
try {
|
|
1782
|
+
raw = readFileSync(candidate.path, 'utf8');
|
|
1783
|
+
} catch (err) {
|
|
1784
|
+
if (!isPermissionError(err)) throw err;
|
|
1785
|
+
continue;
|
|
1786
|
+
}
|
|
1787
|
+
if (!raw.includes(`sessionId: ${sessionId}`)) continue;
|
|
1788
|
+
if (!raw.includes('role: user') && !raw.includes('role: assistant')) continue;
|
|
1789
|
+
|
|
1790
|
+
const parsed = parseMessage(raw);
|
|
1791
|
+
if (!parsed || parsed.sessionId !== sessionId) continue;
|
|
1792
|
+
if (parsed._reflection || parsed.internal || parsed.systemOnly || parsed.systemOnlyMessage) continue;
|
|
1793
|
+
if (parsed.role !== 'user' && parsed.role !== 'assistant') continue;
|
|
1794
|
+
|
|
1795
|
+
if (boundaryCanonical !== null) {
|
|
1796
|
+
if (parsed.role !== 'user') {
|
|
1797
|
+
pendingBoundaryTail.push(parsed);
|
|
1798
|
+
continue;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
const canonical = canonicalUserTurnContent(parsed.content);
|
|
1802
|
+
if (canonical !== boundaryCanonical) {
|
|
1803
|
+
hasMore = true;
|
|
1804
|
+
break;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
if (pendingBoundaryTail.length > 0) {
|
|
1808
|
+
selected.push(...pendingBoundaryTail.splice(0));
|
|
1809
|
+
}
|
|
1810
|
+
selected.push(parsed);
|
|
1811
|
+
continue;
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
selected.push(parsed);
|
|
1815
|
+
if (parsed.role === 'user') {
|
|
1816
|
+
const canonical = canonicalUserTurnContent(parsed.content);
|
|
1817
|
+
if (canonical != null && canonical !== openCanonical) {
|
|
1818
|
+
turnsSeen += 1;
|
|
1819
|
+
openCanonical = canonical;
|
|
1820
|
+
if (turnsSeen === turnsLimit) boundaryCanonical = canonical;
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
return { messages: selected.reverse(), hasMore };
|
|
1739
1826
|
}
|
|
1740
1827
|
|
|
1741
1828
|
// task-314: per-thread sub-directory for forked threads.
|
|
@@ -1796,32 +1883,6 @@ export class ConversationStore {
|
|
|
1796
1883
|
return messages;
|
|
1797
1884
|
}
|
|
1798
1885
|
|
|
1799
|
-
#loadVisibleFromDirBySession(dir, sessionId, beforeSeq) {
|
|
1800
|
-
if (!existsSync(dir)) return [];
|
|
1801
|
-
|
|
1802
|
-
const files = readdirSync(dir)
|
|
1803
|
-
.filter(f => f.endsWith('.md'))
|
|
1804
|
-
.sort();
|
|
1805
|
-
|
|
1806
|
-
const out = [];
|
|
1807
|
-
for (const file of files) {
|
|
1808
|
-
const seq = parseSeqFromId(basename(file, '.md'));
|
|
1809
|
-
if (!Number.isFinite(seq) || seq >= beforeSeq) continue;
|
|
1810
|
-
|
|
1811
|
-
const raw = readFileSync(join(dir, file), 'utf8');
|
|
1812
|
-
if (!raw.includes(`sessionId: ${sessionId}`)) continue;
|
|
1813
|
-
if (!raw.includes('role: user') && !raw.includes('role: assistant')) continue;
|
|
1814
|
-
|
|
1815
|
-
const parsed = parseMessage(raw);
|
|
1816
|
-
if (!parsed || parsed.sessionId !== sessionId) continue;
|
|
1817
|
-
if (parsed._reflection || parsed.internal || parsed.systemOnly || parsed.systemOnlyMessage) continue;
|
|
1818
|
-
if (parsed.role !== 'user' && parsed.role !== 'assistant') continue;
|
|
1819
|
-
out.push(parsed);
|
|
1820
|
-
}
|
|
1821
|
-
|
|
1822
|
-
return out;
|
|
1823
|
-
}
|
|
1824
|
-
|
|
1825
1886
|
/**
|
|
1826
1887
|
* Determine the next sequence number by scanning existing files.
|
|
1827
1888
|
* @returns {number}
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -22,6 +22,7 @@ import { join } from 'node:path';
|
|
|
22
22
|
import { COLLAB_TOOL_POLICY } from './tools/registry.js';
|
|
23
23
|
import { existsSync } from 'node:fs';
|
|
24
24
|
import { randomUUID } from 'node:crypto';
|
|
25
|
+
import { DEFAULT_YEAFT_DIR } from './init.js';
|
|
25
26
|
import { buildDreamOutputSnapshot } from './dream/output-snapshot.js';
|
|
26
27
|
import { Engine } from './engine.js';
|
|
27
28
|
import { loadSession } from './session.js';
|
|
@@ -60,7 +61,7 @@ import {
|
|
|
60
61
|
trimSnapshotForBudget,
|
|
61
62
|
} from './history-compact.js';
|
|
62
63
|
import { persistYeaftAttachments, attachmentsForPersistence, persistedAttachmentPreviewPayload } from './attachments.js';
|
|
63
|
-
import { parseSeqFromId } from './conversation/persist.js';
|
|
64
|
+
import { ConversationStore, parseSeqFromId } from './conversation/persist.js';
|
|
64
65
|
import { sliceLastNTurns } from './turn-utils.js';
|
|
65
66
|
import { pairSanitize } from './pair-sanitize.js';
|
|
66
67
|
import { filterSnapshotForVp } from './snapshot-filter.js';
|
|
@@ -766,6 +767,76 @@ function loadVisibleGroupHistoryPage(store, sessionId, limit, beforeSeq = null)
|
|
|
766
767
|
};
|
|
767
768
|
}
|
|
768
769
|
|
|
770
|
+
function ensureYeaftConversationId() {
|
|
771
|
+
if (!yeaftConversationId) yeaftConversationId = `yeaft-${Date.now()}`;
|
|
772
|
+
return yeaftConversationId;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function emitVisibleHistoryReplay({ store, sessionId, limit, beforeSeq = null, mode = 'recent' }) {
|
|
776
|
+
const visiblePage = sessionId
|
|
777
|
+
? loadVisibleGroupHistoryPage(store, sessionId, limit, beforeSeq)
|
|
778
|
+
: { messages: limit > 0 ? (store.loadRecent?.(limit) || []) : [], oldestSeq: null, hasMore: false };
|
|
779
|
+
const replayEntries = sessionId
|
|
780
|
+
? visiblePage.messages
|
|
781
|
+
: visiblePage.messages
|
|
782
|
+
.map(projectPersistedToVisibleHistoryEntry)
|
|
783
|
+
.filter(Boolean);
|
|
784
|
+
|
|
785
|
+
for (const entry of replayEntries) {
|
|
786
|
+
if (entry.role === 'user') {
|
|
787
|
+
sendSessionOutputFrame({
|
|
788
|
+
type: 'user',
|
|
789
|
+
message: {
|
|
790
|
+
content: entry.content,
|
|
791
|
+
id: entry.id || null,
|
|
792
|
+
...(Array.isArray(entry.attachments) && entry.attachments.length > 0 ? { attachments: hydrateHistoryAttachmentPreviews(entry.attachments) } : {}),
|
|
793
|
+
},
|
|
794
|
+
ts: entry.ts || null,
|
|
795
|
+
}, { sessionId: entry.sessionId || null, threadId: entry.threadId || 'main', turnId: entry.turnId || entry.threadId || 'main' });
|
|
796
|
+
} else if (entry.role === 'assistant') {
|
|
797
|
+
const envelopeOpts = {
|
|
798
|
+
sessionId: entry.sessionId || null,
|
|
799
|
+
threadId: entry.threadId || 'main',
|
|
800
|
+
turnId: entry.turnId || entry.threadId || 'main',
|
|
801
|
+
};
|
|
802
|
+
if (entry.speakerVpId) envelopeOpts.vpId = entry.speakerVpId;
|
|
803
|
+
sendSessionOutputFrame({
|
|
804
|
+
type: 'assistant',
|
|
805
|
+
message: { id: entry.id || null, content: [{ type: 'text', text: entry.content }] },
|
|
806
|
+
ts: entry.ts || null,
|
|
807
|
+
}, envelopeOpts);
|
|
808
|
+
if (Array.isArray(entry.toolCalls) && entry.toolCalls.length > 0) {
|
|
809
|
+
sendSessionOutputFrame({
|
|
810
|
+
type: 'assistant',
|
|
811
|
+
message: {
|
|
812
|
+
content: [{
|
|
813
|
+
type: 'tool_summary',
|
|
814
|
+
count: entry.toolCalls.length,
|
|
815
|
+
omittedCount: entry.toolCalls.length,
|
|
816
|
+
source: 'history',
|
|
817
|
+
}],
|
|
818
|
+
},
|
|
819
|
+
ts: entry.ts || null,
|
|
820
|
+
}, envelopeOpts);
|
|
821
|
+
}
|
|
822
|
+
sendSessionOutputFrame({ type: 'result', result_text: '' }, envelopeOpts);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const latestSeq = replayEntries.length
|
|
827
|
+
? parseSeqFromId(replayEntries[replayEntries.length - 1]?.id)
|
|
828
|
+
: null;
|
|
829
|
+
sendSessionEvent({
|
|
830
|
+
type: 'history_loaded',
|
|
831
|
+
mode,
|
|
832
|
+
count: replayEntries.length,
|
|
833
|
+
sessionId,
|
|
834
|
+
hasMore: visiblePage.hasMore,
|
|
835
|
+
oldestSeq: visiblePage.oldestSeq,
|
|
836
|
+
latestSeq: Number.isFinite(latestSeq) ? latestSeq : null,
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
|
|
769
840
|
/**
|
|
770
841
|
* Hydrate a freshly-created GroupContext's history from the on-disk
|
|
771
842
|
* conversation store. Returns an empty array if the session isn't
|
|
@@ -873,6 +944,7 @@ export function __testGroupHistory(sessionId) {
|
|
|
873
944
|
*/
|
|
874
945
|
export function __testSetSession(sessionLike) {
|
|
875
946
|
session = sessionLike;
|
|
947
|
+
if (!sessionLike) yeaftConversationId = null;
|
|
876
948
|
}
|
|
877
949
|
|
|
878
950
|
/**
|
|
@@ -4303,18 +4375,60 @@ export async function handleYeaftLoadHistory(msg) {
|
|
|
4303
4375
|
// (DEFAULT_RECENT_TURNS = 20 turns).
|
|
4304
4376
|
const pickRecent = (store, lim) =>
|
|
4305
4377
|
sessionId ? store.loadRecentBySession(sessionId, lim) : store.loadRecent(lim);
|
|
4378
|
+
let historyAlreadyReplayed = false;
|
|
4306
4379
|
|
|
4307
4380
|
if (!session) {
|
|
4308
|
-
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
4381
|
+
const yeaftDir = ctx.CONFIG?.yeaftDir || DEFAULT_YEAFT_DIR;
|
|
4382
|
+
const afterSeqRaw = (msg && Number.isFinite(msg.afterSeq)) ? msg.afterSeq : null;
|
|
4383
|
+
const afterMessageId = (msg && typeof msg.afterMessageId === 'string') ? msg.afterMessageId : null;
|
|
4384
|
+
const limit = (typeof msg.limit === 'number') ? msg.limit : 10;
|
|
4385
|
+
ensureYeaftConversationId();
|
|
4386
|
+
|
|
4387
|
+
// First paint must not wait for full Yeaft runtime boot (MCP connects,
|
|
4388
|
+
// skill scans, memory index sync). The conversation markdown store is the
|
|
4389
|
+
// source of truth and can be opened cheaply, so replay the visible message
|
|
4390
|
+
// window immediately, then finish loadSession below for actual turns.
|
|
4391
|
+
const coldStore = new ConversationStore(yeaftDir);
|
|
4392
|
+
if (sessionId && (afterSeqRaw !== null || afterMessageId)) {
|
|
4393
|
+
let afterSeq = afterSeqRaw;
|
|
4394
|
+
if (afterSeq === null && afterMessageId && typeof coldStore.getMessageSeqById === 'function') {
|
|
4395
|
+
afterSeq = coldStore.getMessageSeqById(afterMessageId);
|
|
4396
|
+
}
|
|
4397
|
+
const delta = afterSeq !== null && typeof coldStore.loadAfterSeqByGroup === 'function'
|
|
4398
|
+
? coldStore.loadAfterSeqByGroup(sessionId, afterSeq)
|
|
4399
|
+
: { messages: [], latestSeq: null };
|
|
4400
|
+
for (const entry of delta.messages) {
|
|
4401
|
+
if (entry.role === 'user') {
|
|
4402
|
+
sendSessionOutputFrame({
|
|
4403
|
+
type: 'user',
|
|
4404
|
+
message: { content: entry.content, id: entry.id || null },
|
|
4405
|
+
ts: entry.ts || null,
|
|
4406
|
+
}, { sessionId: entry.sessionId || null, threadId: entry.threadId || 'main', turnId: entry.turnId || entry.threadId || 'main' });
|
|
4407
|
+
} else if (entry.role === 'assistant') {
|
|
4408
|
+
const envelopeOpts = { sessionId: entry.sessionId || null, threadId: entry.threadId || 'main', turnId: entry.turnId || entry.threadId || 'main' };
|
|
4409
|
+
if (entry.speakerVpId) envelopeOpts.vpId = entry.speakerVpId;
|
|
4410
|
+
sendSessionOutputFrame({
|
|
4411
|
+
type: 'assistant',
|
|
4412
|
+
message: { id: entry.id || null, content: [{ type: 'text', text: entry.content }] },
|
|
4413
|
+
ts: entry.ts || null,
|
|
4414
|
+
}, envelopeOpts);
|
|
4415
|
+
sendSessionOutputFrame({ type: 'result', result_text: '' }, envelopeOpts);
|
|
4416
|
+
}
|
|
4417
|
+
}
|
|
4418
|
+
sendSessionEvent({ type: 'history_loaded', mode: 'delta', count: delta.messages.length, sessionId, latestSeq: delta.latestSeq, afterSeq });
|
|
4419
|
+
} else {
|
|
4420
|
+
emitVisibleHistoryReplay({ store: coldStore, sessionId, limit, mode: 'recent' });
|
|
4421
|
+
}
|
|
4422
|
+
historyAlreadyReplayed = true;
|
|
4423
|
+
|
|
4309
4424
|
session = await loadSession({
|
|
4310
|
-
|
|
4425
|
+
dir: yeaftDir,
|
|
4311
4426
|
skipMCP: false,
|
|
4312
4427
|
skipSkills: false,
|
|
4313
4428
|
serverMode: true,
|
|
4314
4429
|
});
|
|
4315
4430
|
installYeaftRuntimeBridge(session);
|
|
4316
4431
|
|
|
4317
|
-
yeaftConversationId = `yeaft-${Date.now()}`;
|
|
4318
4432
|
refreshLiveSessionConfig();
|
|
4319
4433
|
hydrateYeaftStatusFromSession(session, { reason: 'history_load', emitEvent: true });
|
|
4320
4434
|
|
|
@@ -4362,6 +4476,8 @@ export async function handleYeaftLoadHistory(msg) {
|
|
|
4362
4476
|
console.warn('[Yeaft] vp-status snapshot broadcast (replay) failed:', err?.message || err);
|
|
4363
4477
|
}
|
|
4364
4478
|
|
|
4479
|
+
if (historyAlreadyReplayed) return;
|
|
4480
|
+
|
|
4365
4481
|
// Delta path: caller knows the latest seq (or message id) it has cached
|
|
4366
4482
|
// and wants only the messages that arrived after that cursor. Returns
|
|
4367
4483
|
// early with mode:'delta' so the frontend can append+dedupe instead of
|