@yeaft/webchat-agent 1.0.336 → 1.0.337
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/local-runtime/server/handlers/agent-output.js +15 -0
- package/local-runtime/server/handlers/client-conversation.js +13 -1
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +93 -94
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +1 -1
- package/package.json +1 -1
- package/yeaft/conversation/history-index-state.js +294 -0
- package/yeaft/conversation/history-index-worker.js +574 -0
- package/yeaft/conversation/history-index.js +530 -0
- package/yeaft/conversation/persist.js +197 -94
- package/yeaft/conversation/visible-entry.js +143 -0
- package/yeaft/sessions/session-crud.js +65 -2
- package/yeaft/web-bridge.js +199 -23
|
@@ -24,6 +24,12 @@ import { writeAtomic } from '../storage/atomic.js';
|
|
|
24
24
|
import { pairSanitize } from '../pair-sanitize.js';
|
|
25
25
|
import { sliceLastNTurns, stripVpMentionPrefix } from '../turn-utils.js';
|
|
26
26
|
import { isHiddenConversationRow, isVisibleConversationRow } from './internal-control.js';
|
|
27
|
+
import {
|
|
28
|
+
findLiteralSearch,
|
|
29
|
+
iterateCanonicalVisibleEntriesNewestFirst,
|
|
30
|
+
normalizeLiteralSearch,
|
|
31
|
+
} from './visible-entry.js';
|
|
32
|
+
import { markConversationDirty } from './history-index-state.js';
|
|
27
33
|
|
|
28
34
|
/**
|
|
29
35
|
* Default cold-start "recent window" size, expressed in TURNS (not raw
|
|
@@ -134,6 +140,11 @@ function applyFoldedMessageTombstones(rows, additionalIds = []) {
|
|
|
134
140
|
return rows.filter(row => !foldedIds.has(row?.id));
|
|
135
141
|
}
|
|
136
142
|
|
|
143
|
+
function addScanMetric(stats, key, value = 1) {
|
|
144
|
+
if (!stats || typeof stats !== 'object' || !Number.isFinite(value) || value <= 0) return;
|
|
145
|
+
stats[key] = (Number(stats[key]) || 0) + value;
|
|
146
|
+
}
|
|
147
|
+
|
|
137
148
|
function segmentNameForNumber(n) {
|
|
138
149
|
return `${String(n).padStart(6, '0')}.jsonl`;
|
|
139
150
|
}
|
|
@@ -238,6 +249,7 @@ function maybeWarnHistoryTruncated(sessionId, storeDir, recentTurnsLimit, hasCom
|
|
|
238
249
|
* Used to avoid spamming the console with repeated warnings.
|
|
239
250
|
*/
|
|
240
251
|
let _permissionWarned = false;
|
|
252
|
+
let _historyIndexMutationWarned = false;
|
|
241
253
|
|
|
242
254
|
/** Rough token estimation: ~4 chars per token. */
|
|
243
255
|
export function estimateTokens(text) {
|
|
@@ -775,7 +787,7 @@ class SegmentStore {
|
|
|
775
787
|
: out.sort(compareMessagesBySeq);
|
|
776
788
|
}
|
|
777
789
|
|
|
778
|
-
*scan({ beforeSeq = Infinity, afterSeq = -Infinity, desc = false, includeCold = false } = {}) {
|
|
790
|
+
*scan({ beforeSeq = Infinity, afterSeq = -Infinity, desc = false, includeCold = false, scanStats = null } = {}) {
|
|
779
791
|
if (!this.hasData()) return;
|
|
780
792
|
const idx = this.loadIndex();
|
|
781
793
|
const segments = (idx.segments || [])
|
|
@@ -784,6 +796,9 @@ class SegmentStore {
|
|
|
784
796
|
.sort((a, b) => desc ? (b.lastSeq || 0) - (a.lastSeq || 0) : (a.firstSeq || 0) - (b.firstSeq || 0));
|
|
785
797
|
for (const seg of segments) {
|
|
786
798
|
const rows = this.#readSegment(seg.file, { beforeSeq, afterSeq, desc, includeCold });
|
|
799
|
+
addScanMetric(scanStats, 'segments');
|
|
800
|
+
addScanMetric(scanStats, 'bytes', Number(seg.bytes) || 0);
|
|
801
|
+
addScanMetric(scanStats, 'rows', rows.length);
|
|
787
802
|
yield* applyFoldedMessageTombstones(rows, idx.foldedMessageIds);
|
|
788
803
|
}
|
|
789
804
|
}
|
|
@@ -836,7 +851,7 @@ class SegmentStore {
|
|
|
836
851
|
}
|
|
837
852
|
|
|
838
853
|
markCold(id) {
|
|
839
|
-
return this.updateById(id, msg => ({ ...msg, cold: true }))
|
|
854
|
+
return this.updateById(id, msg => ({ ...msg, cold: true }));
|
|
840
855
|
}
|
|
841
856
|
|
|
842
857
|
clear() {
|
|
@@ -1025,6 +1040,40 @@ export class ConversationStore {
|
|
|
1025
1040
|
|
|
1026
1041
|
// ─── Write API ──────────────────────────────────────────
|
|
1027
1042
|
|
|
1043
|
+
#markDirty(message, reason, sourceIds = null) {
|
|
1044
|
+
const sessionId = message?.sessionId || null;
|
|
1045
|
+
try {
|
|
1046
|
+
markConversationDirty({
|
|
1047
|
+
ownerRoot: this.#dir,
|
|
1048
|
+
scopeKind: sessionId ? 'session' : 'chat',
|
|
1049
|
+
scopeId: sessionId || message?.chatId || '*',
|
|
1050
|
+
reason,
|
|
1051
|
+
sourceIds,
|
|
1052
|
+
});
|
|
1053
|
+
} catch (error) {
|
|
1054
|
+
if (!_historyIndexMutationWarned) {
|
|
1055
|
+
console.warn(`[Yeaft] Cannot mark conversation index dirty: ${error?.message || error}`);
|
|
1056
|
+
_historyIndexMutationWarned = true;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
#markAllDirty(reason) {
|
|
1062
|
+
try {
|
|
1063
|
+
markConversationDirty({
|
|
1064
|
+
ownerRoot: this.#dir,
|
|
1065
|
+
scopeKind: 'session',
|
|
1066
|
+
scopeId: '*',
|
|
1067
|
+
reason,
|
|
1068
|
+
});
|
|
1069
|
+
} catch (error) {
|
|
1070
|
+
if (!_historyIndexMutationWarned) {
|
|
1071
|
+
console.warn(`[Yeaft] Cannot mark conversation index dirty: ${error?.message || error}`);
|
|
1072
|
+
_historyIndexMutationWarned = true;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1028
1077
|
/**
|
|
1029
1078
|
* Append a single message to the conversation.
|
|
1030
1079
|
*
|
|
@@ -1055,6 +1104,15 @@ export class ConversationStore {
|
|
|
1055
1104
|
}
|
|
1056
1105
|
|
|
1057
1106
|
this.#nextSeq = seq + 1;
|
|
1107
|
+
if (fullMsg._reflection || (
|
|
1108
|
+
(fullMsg.role === 'user' || fullMsg.role === 'assistant')
|
|
1109
|
+
&& isVisibleConversationRow(fullMsg)
|
|
1110
|
+
)) {
|
|
1111
|
+
this.#markDirty(fullMsg, fullMsg._reflection ? 'fold' : 'append', [
|
|
1112
|
+
fullMsg.id,
|
|
1113
|
+
...(Array.isArray(fullMsg.foldedMessageIds) ? fullMsg.foldedMessageIds : []),
|
|
1114
|
+
]);
|
|
1115
|
+
}
|
|
1058
1116
|
|
|
1059
1117
|
return fullMsg;
|
|
1060
1118
|
}
|
|
@@ -1082,7 +1140,20 @@ export class ConversationStore {
|
|
|
1082
1140
|
if (!message?.id || !patch || typeof patch !== 'object') return null;
|
|
1083
1141
|
try {
|
|
1084
1142
|
const store = this.#segmentStoreFor(message, { create: false });
|
|
1085
|
-
|
|
1143
|
+
let before = null;
|
|
1144
|
+
const updated = store.updateById(message.id, current => {
|
|
1145
|
+
before = { ...current };
|
|
1146
|
+
return { ...current, ...patch };
|
|
1147
|
+
});
|
|
1148
|
+
const affectsCanonicalProjection = row => !!row && (
|
|
1149
|
+
row._reflection
|
|
1150
|
+
|| ((row.role === 'user' || row.role === 'assistant')
|
|
1151
|
+
&& isVisibleConversationRow(row))
|
|
1152
|
+
);
|
|
1153
|
+
if (updated && (affectsCanonicalProjection(before) || affectsCanonicalProjection(updated))) {
|
|
1154
|
+
this.#markDirty(updated, 'update', [message.id]);
|
|
1155
|
+
}
|
|
1156
|
+
return updated;
|
|
1086
1157
|
} catch (err) {
|
|
1087
1158
|
if (isPermissionError(err)) {
|
|
1088
1159
|
if (!_permissionWarned) {
|
|
@@ -1122,7 +1193,7 @@ export class ConversationStore {
|
|
|
1122
1193
|
moveToCold(id) {
|
|
1123
1194
|
for (const dir of [this.#chatDir, ...this.#sessionConversationDirs({ primaryOnly: true })]) {
|
|
1124
1195
|
const moved = this.#segmentStoreForConversationDir(dir).markCold(id);
|
|
1125
|
-
if (moved
|
|
1196
|
+
if (moved) return;
|
|
1126
1197
|
}
|
|
1127
1198
|
for (const [hotDir, coldDir] of this.#hotColdDirPairs({ includeLegacy: false })) {
|
|
1128
1199
|
const src = join(hotDir, `${id}.md`);
|
|
@@ -1130,6 +1201,7 @@ export class ConversationStore {
|
|
|
1130
1201
|
if (!existsSync(src)) continue;
|
|
1131
1202
|
try {
|
|
1132
1203
|
renameSync(src, dst);
|
|
1204
|
+
|
|
1133
1205
|
} catch (err) {
|
|
1134
1206
|
if (isPermissionError(err)) {
|
|
1135
1207
|
if (!_permissionWarned) {
|
|
@@ -1378,6 +1450,7 @@ export class ConversationStore {
|
|
|
1378
1450
|
}
|
|
1379
1451
|
this.#nextSeq = 1;
|
|
1380
1452
|
this.updateIndex({ totalMessages: 0, lastMessageId: null });
|
|
1453
|
+
this.#markAllDirty('clear');
|
|
1381
1454
|
}
|
|
1382
1455
|
|
|
1383
1456
|
|
|
@@ -1618,24 +1691,37 @@ export class ConversationStore {
|
|
|
1618
1691
|
* @param {number} [turnsLimit=DEFAULT_RECENT_TURNS]
|
|
1619
1692
|
* @returns {{ messages: object[], oldestSeq: number|null, hasMore: boolean }}
|
|
1620
1693
|
*/
|
|
1621
|
-
loadVisibleBySession(sessionId, beforeSeq, turnsLimit = DEFAULT_RECENT_TURNS) {
|
|
1694
|
+
loadVisibleBySession(sessionId, beforeSeq, turnsLimit = DEFAULT_RECENT_TURNS, opts = {}) {
|
|
1622
1695
|
if (!sessionId || !(turnsLimit > 0)) return { messages: [], oldestSeq: null, hasMore: false };
|
|
1623
1696
|
|
|
1624
1697
|
const cutoff = Number.isFinite(beforeSeq) ? beforeSeq : Infinity;
|
|
1698
|
+
const stopAtSeq = Number.isFinite(opts.stopAtSeq) ? Math.max(1, opts.stopAtSeq) : null;
|
|
1625
1699
|
const page = this.#loadRecentSessionWindow(sessionId, turnsLimit, {
|
|
1626
1700
|
beforeSeq: cutoff,
|
|
1701
|
+
afterSeq: stopAtSeq === null ? -Infinity : stopAtSeq - 1,
|
|
1627
1702
|
roles: null,
|
|
1628
1703
|
stripAssistantToolCalls: false,
|
|
1629
1704
|
visibleOnly: true,
|
|
1630
1705
|
});
|
|
1631
1706
|
const messages = projectVisibleSessionMessages(page.messages);
|
|
1632
|
-
if (messages.length === 0)
|
|
1707
|
+
if (messages.length === 0) {
|
|
1708
|
+
const nextBeforeSeq = Number.isFinite(page.nextBeforeSeq) ? page.nextBeforeSeq : null;
|
|
1709
|
+
return {
|
|
1710
|
+
messages: [],
|
|
1711
|
+
oldestSeq: null,
|
|
1712
|
+
nextBeforeSeq,
|
|
1713
|
+
hasMore: page.truncated && (stopAtSeq === null || nextBeforeSeq > stopAtSeq),
|
|
1714
|
+
};
|
|
1715
|
+
}
|
|
1633
1716
|
|
|
1634
1717
|
const oldestSeq = messages.length ? parseSeqFromId(messages[0].id) : null;
|
|
1635
1718
|
return {
|
|
1636
1719
|
messages,
|
|
1637
1720
|
oldestSeq: Number.isFinite(oldestSeq) ? oldestSeq : null,
|
|
1638
|
-
|
|
1721
|
+
nextBeforeSeq: Number.isFinite(page.nextBeforeSeq)
|
|
1722
|
+
? Math.min(page.nextBeforeSeq, oldestSeq)
|
|
1723
|
+
: (Number.isFinite(oldestSeq) ? oldestSeq : null),
|
|
1724
|
+
hasMore: page.truncated && (stopAtSeq === null || oldestSeq > stopAtSeq),
|
|
1639
1725
|
};
|
|
1640
1726
|
}
|
|
1641
1727
|
|
|
@@ -1751,6 +1837,23 @@ export class ConversationStore {
|
|
|
1751
1837
|
return Number.isFinite(seq) ? seq : null;
|
|
1752
1838
|
}
|
|
1753
1839
|
|
|
1840
|
+
/**
|
|
1841
|
+
* Return every canonical visible entry newest-first. This is the single read
|
|
1842
|
+
* model used by JSONL fallback search and the rebuildable SQLite worker.
|
|
1843
|
+
*
|
|
1844
|
+
* @param {string} sessionId
|
|
1845
|
+
* @param {{ scanStats?: object }} [opts]
|
|
1846
|
+
* @returns {object[]}
|
|
1847
|
+
*/
|
|
1848
|
+
*iterateCanonicalVisibleEntriesBySession(sessionId, opts = {}) {
|
|
1849
|
+
if (!sessionId) return;
|
|
1850
|
+
yield* this.#iterateVisibleResponseEntries(sessionId, { scanStats: opts.scanStats });
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
loadCanonicalVisibleEntriesBySession(sessionId, opts = {}) {
|
|
1854
|
+
return Array.from(this.iterateCanonicalVisibleEntriesBySession(sessionId, opts));
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1754
1857
|
/**
|
|
1755
1858
|
* Search user-visible messages inside one Session. The scan is newest-first
|
|
1756
1859
|
* and stops as soon as one page plus a `hasMore` sentinel is found, so a
|
|
@@ -1758,11 +1861,11 @@ export class ConversationStore {
|
|
|
1758
1861
|
*
|
|
1759
1862
|
* @param {string} sessionId
|
|
1760
1863
|
* @param {string} query
|
|
1761
|
-
* @param {{ limit?: number, beforeSeq?: number|null, senderKey?: string }} [opts]
|
|
1864
|
+
* @param {{ limit?: number, beforeSeq?: number|null, senderKey?: string, scanStats?: object }} [opts]
|
|
1762
1865
|
* @returns {{ results: object[], hasMore: boolean, nextBeforeSeq: number|null }}
|
|
1763
1866
|
*/
|
|
1764
1867
|
searchVisibleBySession(sessionId, query, opts = {}) {
|
|
1765
|
-
const needle = typeof query === 'string' ? query.trim()
|
|
1868
|
+
const needle = normalizeLiteralSearch(typeof query === 'string' ? query.trim() : '');
|
|
1766
1869
|
const senderKey = typeof opts.senderKey === 'string' ? opts.senderKey : '';
|
|
1767
1870
|
if (!sessionId || (needle.length < 2 && !senderKey)) return { results: [], hasMore: false, nextBeforeSeq: null };
|
|
1768
1871
|
|
|
@@ -1771,7 +1874,7 @@ export class ConversationStore {
|
|
|
1771
1874
|
const results = [];
|
|
1772
1875
|
let hasMore = false;
|
|
1773
1876
|
|
|
1774
|
-
for (const entry of this.#iterateVisibleResponseEntries(sessionId, { beforeSeq })) {
|
|
1877
|
+
for (const entry of this.#iterateVisibleResponseEntries(sessionId, { beforeSeq, scanStats: opts.scanStats })) {
|
|
1775
1878
|
const senderMatches = senderKey === 'user'
|
|
1776
1879
|
? entry.role === 'user'
|
|
1777
1880
|
: (senderKey.startsWith('vp:')
|
|
@@ -1779,7 +1882,7 @@ export class ConversationStore {
|
|
|
1779
1882
|
: true);
|
|
1780
1883
|
if (!senderMatches) continue;
|
|
1781
1884
|
const text = entry.textParts.join(' ');
|
|
1782
|
-
const matchIndex = needle ? text
|
|
1885
|
+
const matchIndex = needle ? findLiteralSearch(text, needle) : 0;
|
|
1783
1886
|
if (matchIndex < 0) continue;
|
|
1784
1887
|
if (results.length >= limit) {
|
|
1785
1888
|
hasMore = true;
|
|
@@ -1807,7 +1910,7 @@ export class ConversationStore {
|
|
|
1807
1910
|
* bodies never leave the Agent through this API.
|
|
1808
1911
|
*
|
|
1809
1912
|
* @param {string} sessionId
|
|
1810
|
-
* @param {{ limit?: number, beforeSeq?: number|null, includeTotal?: boolean }} [opts]
|
|
1913
|
+
* @param {{ limit?: number, beforeSeq?: number|null, includeTotal?: boolean, scanStats?: object }} [opts]
|
|
1811
1914
|
* @returns {{ results: object[], hasMore: boolean, nextBeforeSeq: number|null, totalCount: number|null }}
|
|
1812
1915
|
*/
|
|
1813
1916
|
loadVisibleOutlineBySession(sessionId, opts = {}) {
|
|
@@ -1818,7 +1921,7 @@ export class ConversationStore {
|
|
|
1818
1921
|
const newestFirst = [];
|
|
1819
1922
|
let hasMore = false;
|
|
1820
1923
|
|
|
1821
|
-
for (const entry of this.#iterateVisibleResponseEntries(sessionId, { beforeSeq })) {
|
|
1924
|
+
for (const entry of this.#iterateVisibleResponseEntries(sessionId, { beforeSeq, scanStats: opts.scanStats })) {
|
|
1822
1925
|
if (newestFirst.length >= limit) {
|
|
1823
1926
|
hasMore = true;
|
|
1824
1927
|
break;
|
|
@@ -1831,9 +1934,9 @@ export class ConversationStore {
|
|
|
1831
1934
|
}
|
|
1832
1935
|
|
|
1833
1936
|
let totalCount = null;
|
|
1834
|
-
if (opts.includeTotal
|
|
1937
|
+
if (opts.includeTotal === true) {
|
|
1835
1938
|
totalCount = 0;
|
|
1836
|
-
for (const _entry of this.#iterateVisibleResponseEntries(sessionId)) totalCount += 1;
|
|
1939
|
+
for (const _entry of this.#iterateVisibleResponseEntries(sessionId, { scanStats: opts.scanStats })) totalCount += 1;
|
|
1837
1940
|
}
|
|
1838
1941
|
|
|
1839
1942
|
const oldestEntry = newestFirst[newestFirst.length - 1] || null;
|
|
@@ -1853,7 +1956,9 @@ export class ConversationStore {
|
|
|
1853
1956
|
*
|
|
1854
1957
|
* @param {string} sessionId
|
|
1855
1958
|
* @param {number} anchorSeq
|
|
1856
|
-
* @param {{ beforeTurns?: number, afterTurns?: number
|
|
1959
|
+
* @param {{ beforeTurns?: number, afterTurns?: number, entryStartSeq?: number,
|
|
1960
|
+
* entryEndSeq?: number, sourceMessageIds?: string[], maxRows?: number,
|
|
1961
|
+
* maxBytes?: number }} [opts]
|
|
1857
1962
|
* @returns {{ messages: object[], oldestSeq: number|null, hasMoreBefore: boolean }}
|
|
1858
1963
|
*/
|
|
1859
1964
|
loadVisibleWindowBySession(sessionId, anchorSeq, opts = {}) {
|
|
@@ -1884,13 +1989,47 @@ export class ConversationStore {
|
|
|
1884
1989
|
messages.push(message);
|
|
1885
1990
|
}
|
|
1886
1991
|
|
|
1992
|
+
const anchorIds = new Set(Array.isArray(opts.sourceMessageIds) ? opts.sourceMessageIds : []);
|
|
1993
|
+
const entryStartSeq = Number.isFinite(opts.entryStartSeq) ? opts.entryStartSeq : anchorSeq;
|
|
1994
|
+
const entryEndSeq = Number.isFinite(opts.entryEndSeq) ? opts.entryEndSeq : anchorSeq;
|
|
1995
|
+
for (const message of this.#iterateSessionRows(sessionId, {
|
|
1996
|
+
afterSeq: entryStartSeq - 1,
|
|
1997
|
+
beforeSeq: entryEndSeq + 1,
|
|
1998
|
+
desc: false,
|
|
1999
|
+
})) {
|
|
2000
|
+
if (!message?.id || seen.has(message.id)) continue;
|
|
2001
|
+
if (anchorIds.size > 0 && !anchorIds.has(message.id)) continue;
|
|
2002
|
+
seen.add(message.id);
|
|
2003
|
+
messages.push(message);
|
|
2004
|
+
}
|
|
2005
|
+
|
|
1887
2006
|
messages.sort(compareMessagesBySeq);
|
|
1888
|
-
const
|
|
1889
|
-
const
|
|
2007
|
+
const projected = projectVisibleSessionMessages(messages);
|
|
2008
|
+
const maxRows = Math.min(500, Math.max(10, Number.isFinite(opts.maxRows) ? Math.floor(opts.maxRows) : 200));
|
|
2009
|
+
const maxBytes = Math.min(2 * 1024 * 1024, Math.max(32 * 1024, Number.isFinite(opts.maxBytes) ? Math.floor(opts.maxBytes) : 512 * 1024));
|
|
2010
|
+
const projectedById = new Map(projected.map(message => [message?.id, message]));
|
|
2011
|
+
const anchorRows = Array.from(anchorIds, id => projectedById.get(id)).filter(Boolean);
|
|
2012
|
+
const selected = anchorRows.slice();
|
|
2013
|
+
const selectedIds = new Set(selected.map(message => message.id));
|
|
2014
|
+
let selectedBytes = selected.reduce((sum, message) => sum + Buffer.byteLength(JSON.stringify(message)), 0);
|
|
2015
|
+
const candidates = projected
|
|
2016
|
+
.filter(message => !selectedIds.has(message?.id))
|
|
2017
|
+
.sort((a, b) => Math.abs(parseSeqFromId(a.id) - anchorSeq) - Math.abs(parseSeqFromId(b.id) - anchorSeq));
|
|
2018
|
+
for (const message of candidates) {
|
|
2019
|
+
if (selected.length >= maxRows) break;
|
|
2020
|
+
const bytes = Buffer.byteLength(JSON.stringify(message));
|
|
2021
|
+
if (selected.length > 0 && selectedBytes + bytes > maxBytes) continue;
|
|
2022
|
+
selected.push(message);
|
|
2023
|
+
selectedBytes += bytes;
|
|
2024
|
+
}
|
|
2025
|
+
selected.sort(compareMessagesBySeq);
|
|
2026
|
+
const oldestSeq = selected.length > 0 ? parseSeqFromId(selected[0].id) : null;
|
|
1890
2027
|
return {
|
|
1891
|
-
messages:
|
|
2028
|
+
messages: selected,
|
|
1892
2029
|
oldestSeq: Number.isFinite(oldestSeq) ? oldestSeq : null,
|
|
1893
|
-
hasMoreBefore: beforeRaw.truncated,
|
|
2030
|
+
hasMoreBefore: beforeRaw.truncated || selected.length < projected.length,
|
|
2031
|
+
rowCount: selected.length,
|
|
2032
|
+
byteCount: selectedBytes,
|
|
1894
2033
|
};
|
|
1895
2034
|
}
|
|
1896
2035
|
|
|
@@ -2008,6 +2147,7 @@ export class ConversationStore {
|
|
|
2008
2147
|
}
|
|
2009
2148
|
}
|
|
2010
2149
|
this.#nextSeq = null;
|
|
2150
|
+
this.#markDirty({ sessionId }, 'delete-session');
|
|
2011
2151
|
return removed;
|
|
2012
2152
|
}
|
|
2013
2153
|
|
|
@@ -2092,7 +2232,10 @@ export class ConversationStore {
|
|
|
2092
2232
|
}
|
|
2093
2233
|
}
|
|
2094
2234
|
}
|
|
2095
|
-
if (removed > 0)
|
|
2235
|
+
if (removed > 0) {
|
|
2236
|
+
this.#nextSeq = null;
|
|
2237
|
+
this.#markAllDirty('compact-orphans');
|
|
2238
|
+
}
|
|
2096
2239
|
return { scanned, removed, orphans, skipped: false };
|
|
2097
2240
|
}
|
|
2098
2241
|
|
|
@@ -2168,6 +2311,7 @@ export class ConversationStore {
|
|
|
2168
2311
|
}
|
|
2169
2312
|
}
|
|
2170
2313
|
}
|
|
2314
|
+
if (rewritten > 0) this.#markAllDirty('reassign-thread');
|
|
2171
2315
|
return rewritten;
|
|
2172
2316
|
}
|
|
2173
2317
|
|
|
@@ -2266,6 +2410,7 @@ export class ConversationStore {
|
|
|
2266
2410
|
throw err;
|
|
2267
2411
|
}
|
|
2268
2412
|
}
|
|
2413
|
+
if (copied > 0) this.#markAllDirty('copy-thread');
|
|
2269
2414
|
return copied;
|
|
2270
2415
|
}
|
|
2271
2416
|
|
|
@@ -2523,6 +2668,7 @@ export class ConversationStore {
|
|
|
2523
2668
|
|
|
2524
2669
|
#loadRecentSessionWindow(sessionId, turnsLimit, {
|
|
2525
2670
|
beforeSeq = Infinity,
|
|
2671
|
+
afterSeq = -Infinity,
|
|
2526
2672
|
roles = null,
|
|
2527
2673
|
stripAssistantToolCalls = false,
|
|
2528
2674
|
includeReflections = false,
|
|
@@ -2535,6 +2681,8 @@ export class ConversationStore {
|
|
|
2535
2681
|
let boundaryCanonical = null;
|
|
2536
2682
|
let truncated = false;
|
|
2537
2683
|
let parsed = 0;
|
|
2684
|
+
let oldestScannedSeq = null;
|
|
2685
|
+
let scanCapped = false;
|
|
2538
2686
|
const scanCap = recentSessionScanCap(turnsLimit);
|
|
2539
2687
|
|
|
2540
2688
|
const project = (m) => {
|
|
@@ -2562,14 +2710,17 @@ export class ConversationStore {
|
|
|
2562
2710
|
// and stop after the requested turn window is complete. Hidden/internal and
|
|
2563
2711
|
// non-turn rows are not allowed to force an unbounded scan; a hard parse cap
|
|
2564
2712
|
// conservatively marks the page truncated.
|
|
2565
|
-
for (const m of this.#iterateSessionRows(sessionId, { beforeSeq, desc: true })) {
|
|
2713
|
+
for (const m of this.#iterateSessionRows(sessionId, { beforeSeq, afterSeq, desc: true })) {
|
|
2566
2714
|
if (parsed >= scanCap) {
|
|
2567
2715
|
truncated = true;
|
|
2716
|
+
scanCapped = true;
|
|
2568
2717
|
break;
|
|
2569
2718
|
}
|
|
2570
2719
|
parsed += 1;
|
|
2571
2720
|
|
|
2572
2721
|
if (!m || m.sessionId !== sessionId) continue;
|
|
2722
|
+
const scannedSeq = parseSeqFromId(m.id);
|
|
2723
|
+
if (Number.isFinite(scannedSeq)) oldestScannedSeq = scannedSeq;
|
|
2573
2724
|
|
|
2574
2725
|
const boundaryComplete = turnsFromEnd >= turnsLimit;
|
|
2575
2726
|
const hidden = isHiddenConversationRow(m)
|
|
@@ -2618,93 +2769,40 @@ export class ConversationStore {
|
|
|
2618
2769
|
return {
|
|
2619
2770
|
messages: turnsFromEnd > 0 ? sliceLastNTurns(kept, turnsLimit) : kept,
|
|
2620
2771
|
truncated,
|
|
2772
|
+
nextBeforeSeq: scanCapped
|
|
2773
|
+
&& pendingBoundaryRows.length === 0
|
|
2774
|
+
&& Number.isFinite(oldestScannedSeq)
|
|
2775
|
+
? oldestScannedSeq
|
|
2776
|
+
: null,
|
|
2621
2777
|
};
|
|
2622
2778
|
}
|
|
2623
2779
|
|
|
2624
2780
|
*#iterateVisibleResponseEntries(sessionId, opts = {}) {
|
|
2625
2781
|
const beforeSeq = Number.isFinite(opts.beforeSeq) ? opts.beforeSeq : Infinity;
|
|
2626
|
-
const
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
if (!message || message.sessionId !== sessionId || !isVisibleConversationRow(message)) return null;
|
|
2631
|
-
if (message.role !== 'user' && message.role !== 'assistant') return null;
|
|
2632
|
-
if (!message.id || seen.has(message.id)) return null;
|
|
2633
|
-
seen.add(message.id);
|
|
2634
|
-
const seq = parseSeqFromId(message.id);
|
|
2635
|
-
if (!Number.isFinite(seq)) return null;
|
|
2636
|
-
const text = this.#visibleSearchText(message.content);
|
|
2637
|
-
// Session logs have used three sender shapes over time. Resolve them at
|
|
2638
|
-
// the read boundary so existing data works without a disk migration.
|
|
2639
|
-
const speakerVpId = message.speakerVpId || message.meta?.senderVpId
|
|
2640
|
-
|| (message.role === 'assistant' && message.from && message.from !== 'user'
|
|
2641
|
-
? message.from : null);
|
|
2642
|
-
return {
|
|
2643
|
-
message,
|
|
2644
|
-
seq,
|
|
2645
|
-
text,
|
|
2646
|
-
speakerVpId,
|
|
2647
|
-
groupKey: message.role === 'assistant'
|
|
2648
|
-
? `assistant:${message.turnId || message.id}:${speakerVpId || ''}`
|
|
2649
|
-
: `user:${message.id}`,
|
|
2650
|
-
};
|
|
2651
|
-
};
|
|
2652
|
-
const startEntry = (row) => ({
|
|
2653
|
-
groupKey: row.groupKey,
|
|
2654
|
-
role: row.message.role,
|
|
2655
|
-
turnId: row.message.turnId || row.message.threadId || row.message.id,
|
|
2656
|
-
speakerVpId: row.speakerVpId,
|
|
2657
|
-
oldestSeq: row.seq,
|
|
2658
|
-
anchor: row,
|
|
2659
|
-
anchorHasText: !!row.text,
|
|
2660
|
-
textParts: row.text ? [row.text] : [],
|
|
2782
|
+
const rows = this.#iterateSessionRows(sessionId, {
|
|
2783
|
+
beforeSeq,
|
|
2784
|
+
desc: true,
|
|
2785
|
+
scanStats: opts.scanStats,
|
|
2661
2786
|
});
|
|
2662
|
-
|
|
2663
|
-
entry.oldestSeq = Math.min(entry.oldestSeq, row.seq);
|
|
2664
|
-
if (row.text) entry.textParts.unshift(row.text);
|
|
2665
|
-
if (!entry.anchorHasText && row.text) {
|
|
2666
|
-
entry.anchor = row;
|
|
2667
|
-
entry.anchorHasText = true;
|
|
2668
|
-
}
|
|
2669
|
-
};
|
|
2670
|
-
|
|
2671
|
-
for (const message of this.#iterateSessionRows(sessionId, { beforeSeq, desc: true })) {
|
|
2672
|
-
const row = visibleRow(message);
|
|
2673
|
-
if (!row) continue;
|
|
2674
|
-
if (current && current.groupKey === row.groupKey) {
|
|
2675
|
-
mergeRow(current, row);
|
|
2676
|
-
continue;
|
|
2677
|
-
}
|
|
2678
|
-
if (current) yield current;
|
|
2679
|
-
current = startEntry(row);
|
|
2680
|
-
}
|
|
2681
|
-
if (current) yield current;
|
|
2787
|
+
yield* iterateCanonicalVisibleEntriesNewestFirst(rows, sessionId);
|
|
2682
2788
|
}
|
|
2683
2789
|
|
|
2684
2790
|
#projectVisibleResponseEntry(entry) {
|
|
2685
2791
|
return {
|
|
2686
|
-
|
|
2687
|
-
|
|
2792
|
+
entryId: entry.entryId,
|
|
2793
|
+
messageId: entry.anchorMessageId,
|
|
2794
|
+
...(entry.clientMessageId ? { clientMessageId: entry.clientMessageId } : {}),
|
|
2688
2795
|
turnId: entry.turnId,
|
|
2689
|
-
seq: entry.
|
|
2796
|
+
seq: entry.anchorSeq,
|
|
2797
|
+
entryStartSeq: entry.entryStartSeq,
|
|
2690
2798
|
role: entry.role,
|
|
2691
2799
|
speakerVpId: entry.speakerVpId,
|
|
2692
|
-
|
|
2693
|
-
|
|
2800
|
+
sourceMessageIds: entry.sourceMessageIds,
|
|
2801
|
+
timestamp: entry.timestamp,
|
|
2802
|
+
_beforeSeq: entry.entryStartSeq,
|
|
2694
2803
|
};
|
|
2695
2804
|
}
|
|
2696
2805
|
|
|
2697
|
-
#visibleSearchText(content) {
|
|
2698
|
-
if (typeof content === 'string') return content.replace(/\s+/g, ' ').trim();
|
|
2699
|
-
if (!Array.isArray(content)) return '';
|
|
2700
|
-
return content
|
|
2701
|
-
.filter(part => part && typeof part === 'object' && part.type === 'text')
|
|
2702
|
-
.map(part => typeof part.text === 'string' ? part.text : '')
|
|
2703
|
-
.join(' ')
|
|
2704
|
-
.replace(/\s+/g, ' ')
|
|
2705
|
-
.trim();
|
|
2706
|
-
}
|
|
2707
|
-
|
|
2708
2806
|
#searchSnippet(text, matchIndex, needleLength) {
|
|
2709
2807
|
const radius = 90;
|
|
2710
2808
|
const start = Math.max(0, matchIndex - radius);
|
|
@@ -2728,7 +2826,12 @@ export class ConversationStore {
|
|
|
2728
2826
|
for (const entry of this.#sessionFileEntries('all', sessionId, opts)) {
|
|
2729
2827
|
try {
|
|
2730
2828
|
const msg = this.readMessageFile(entry.path);
|
|
2731
|
-
|
|
2829
|
+
addScanMetric(opts.scanStats, 'legacyFiles');
|
|
2830
|
+
try { addScanMetric(opts.scanStats, 'bytes', statSync(entry.path).size); } catch {}
|
|
2831
|
+
if (msg) {
|
|
2832
|
+
addScanMetric(opts.scanStats, 'rows');
|
|
2833
|
+
yield msg;
|
|
2834
|
+
}
|
|
2732
2835
|
} catch (err) {
|
|
2733
2836
|
if (isPermissionError(err)) continue;
|
|
2734
2837
|
throw err;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { isVisibleConversationRow } from './internal-control.js';
|
|
3
|
+
|
|
4
|
+
export const VISIBLE_ENTRY_SCHEMA_VERSION = 1;
|
|
5
|
+
|
|
6
|
+
function messageSeq(message) {
|
|
7
|
+
if (Number.isFinite(message?.seq)) return Number(message.seq);
|
|
8
|
+
const match = typeof message?.id === 'string' ? message.id.match(/^m(\d+)$/) : null;
|
|
9
|
+
return match ? Number.parseInt(match[1], 10) : null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function visibleText(content) {
|
|
13
|
+
if (typeof content === 'string') return content.replace(/\s+/g, ' ').trim();
|
|
14
|
+
if (!Array.isArray(content)) return '';
|
|
15
|
+
return content
|
|
16
|
+
.filter(part => part && typeof part === 'object' && part.type === 'text')
|
|
17
|
+
.map(part => typeof part.text === 'string' ? part.text : '')
|
|
18
|
+
.join(' ')
|
|
19
|
+
.replace(/\s+/g, ' ')
|
|
20
|
+
.trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function speakerVpId(message) {
|
|
24
|
+
return message?.speakerVpId || message?.meta?.senderVpId
|
|
25
|
+
|| (message?.role === 'assistant' && message?.from && message.from !== 'user'
|
|
26
|
+
? message.from : null);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function entryIdentity(message, speaker) {
|
|
30
|
+
if (message.role === 'user') return `user:${message.id}`;
|
|
31
|
+
const turnId = typeof message.turnId === 'string' && message.turnId ? message.turnId : null;
|
|
32
|
+
return turnId
|
|
33
|
+
? `assistant:turn:${turnId}:speaker:${speaker || ''}`
|
|
34
|
+
: `assistant:message:${message.id}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function entryId(sessionId, identity) {
|
|
38
|
+
const digest = createHash('sha256')
|
|
39
|
+
.update(`${VISIBLE_ENTRY_SCHEMA_VERSION}\0${sessionId}\0${identity}`, 'utf8')
|
|
40
|
+
.digest('base64url')
|
|
41
|
+
.slice(0, 22);
|
|
42
|
+
return `entry_${digest}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function visibleRow(message, sessionId, seenMessageIds) {
|
|
46
|
+
if (!message || message.sessionId !== sessionId || !isVisibleConversationRow(message)) return null;
|
|
47
|
+
if (message.role !== 'user' && message.role !== 'assistant') return null;
|
|
48
|
+
if (!message.id || seenMessageIds.has(message.id)) return null;
|
|
49
|
+
const seq = messageSeq(message);
|
|
50
|
+
if (!Number.isFinite(seq)) return null;
|
|
51
|
+
seenMessageIds.add(message.id);
|
|
52
|
+
const speaker = speakerVpId(message);
|
|
53
|
+
return {
|
|
54
|
+
message,
|
|
55
|
+
seq,
|
|
56
|
+
text: visibleText(message.content),
|
|
57
|
+
speakerVpId: speaker,
|
|
58
|
+
identity: entryIdentity(message, speaker),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function projectEntry(sessionId, identity, rows) {
|
|
63
|
+
const ordered = rows.slice().sort((a, b) => a.seq - b.seq);
|
|
64
|
+
const latest = ordered[ordered.length - 1];
|
|
65
|
+
const latestText = ordered.slice().reverse().find(row => row.text) || latest;
|
|
66
|
+
const first = ordered[0];
|
|
67
|
+
const message = latestText.message;
|
|
68
|
+
return {
|
|
69
|
+
entryId: entryId(sessionId, identity),
|
|
70
|
+
role: first.message.role,
|
|
71
|
+
turnId: first.message.turnId || first.message.threadId || first.message.id,
|
|
72
|
+
speakerVpId: first.speakerVpId,
|
|
73
|
+
entryStartSeq: first.seq,
|
|
74
|
+
entryEndSeq: latest.seq,
|
|
75
|
+
anchorMessageId: message.id,
|
|
76
|
+
anchorSeq: latestText.seq,
|
|
77
|
+
sourceMessageIds: ordered.map(row => row.message.id),
|
|
78
|
+
textParts: ordered.filter(row => row.text).map(row => row.text),
|
|
79
|
+
timestamp: message.ts || message.time || null,
|
|
80
|
+
...(message.clientMessageId ? { clientMessageId: message.clientMessageId } : {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function flushAssistantEntries(sessionId, entries, boundaryMessageId = null) {
|
|
85
|
+
const projected = Array.from(entries.entries(), ([identity, rows]) => {
|
|
86
|
+
const oldest = rows.reduce((candidate, row) => (
|
|
87
|
+
!candidate || row.seq < candidate.seq ? row : candidate
|
|
88
|
+
), null);
|
|
89
|
+
const boundaryIdentity = boundaryMessageId || `start:${oldest?.message?.id || 'unknown'}`;
|
|
90
|
+
return projectEntry(sessionId, `${identity}:after:${boundaryIdentity}`, rows);
|
|
91
|
+
});
|
|
92
|
+
projected.sort((a, b) => b.entryEndSeq - a.entryEndSeq || b.entryStartSeq - a.entryStartSeq);
|
|
93
|
+
entries.clear();
|
|
94
|
+
return projected;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Project visible persisted rows into canonical user/assistant entries.
|
|
99
|
+
*
|
|
100
|
+
* Input must be newest-first. A visible user row closes the assistant response
|
|
101
|
+
* bucket above it. That boundary lets interleaved VP rows (A-B-A) coalesce by
|
|
102
|
+
* explicit turnId + speaker without scanning the whole transcript. Legacy
|
|
103
|
+
* assistant rows without a turnId remain separate because guessing from
|
|
104
|
+
* adjacency would make their identity change after prepend or compaction.
|
|
105
|
+
*/
|
|
106
|
+
export function* iterateCanonicalVisibleEntriesNewestFirst(messages, sessionId) {
|
|
107
|
+
const seenMessageIds = new Set();
|
|
108
|
+
const assistantEntries = new Map();
|
|
109
|
+
|
|
110
|
+
for (const message of messages) {
|
|
111
|
+
const row = visibleRow(message, sessionId, seenMessageIds);
|
|
112
|
+
if (!row) continue;
|
|
113
|
+
if (row.message.role === 'user') {
|
|
114
|
+
yield* flushAssistantEntries(sessionId, assistantEntries, row.message.id);
|
|
115
|
+
yield projectEntry(sessionId, row.identity, [row]);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const rows = assistantEntries.get(row.identity) || [];
|
|
119
|
+
rows.push(row);
|
|
120
|
+
assistantEntries.set(row.identity, rows);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
yield* flushAssistantEntries(sessionId, assistantEntries);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function normalizeLiteralSearch(value) {
|
|
127
|
+
return typeof value === 'string' ? value.toLocaleLowerCase() : '';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function findLiteralSearch(text, query) {
|
|
131
|
+
const needle = normalizeLiteralSearch(query);
|
|
132
|
+
if (!needle) return 0;
|
|
133
|
+
return normalizeLiteralSearch(text).indexOf(needle);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const __visibleEntryForTest = {
|
|
137
|
+
entryIdentity,
|
|
138
|
+
entryId,
|
|
139
|
+
messageSeq,
|
|
140
|
+
projectEntry,
|
|
141
|
+
speakerVpId,
|
|
142
|
+
visibleText,
|
|
143
|
+
};
|