@yeaft/webchat-agent 1.0.335 → 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
|
Binary file
|
package/package.json
CHANGED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
appendFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
closeSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
openSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
readSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
statSync,
|
|
13
|
+
} from 'node:fs';
|
|
14
|
+
import { join, relative } from 'node:path';
|
|
15
|
+
import { writeAtomic } from '../storage/atomic.js';
|
|
16
|
+
|
|
17
|
+
const STATE_VERSION = 1;
|
|
18
|
+
const INDEX_DIR = 'conversation-index';
|
|
19
|
+
const STATE_FILE = 'mutation-state.json';
|
|
20
|
+
const JOURNAL_FILE = 'mutations.jsonl';
|
|
21
|
+
const FLUSH_DELAY_MS = 25;
|
|
22
|
+
const MAX_JOURNAL_BYTES = 2 * 1024 * 1024;
|
|
23
|
+
const stateCache = new Map();
|
|
24
|
+
const pendingEvents = new Map();
|
|
25
|
+
const flushTimers = new Map();
|
|
26
|
+
|
|
27
|
+
function scopeKey(scopeKind, scopeId) {
|
|
28
|
+
return `${scopeKind || 'session'}:${scopeId || '*'}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function safeJson(path, fallback) {
|
|
32
|
+
if (!existsSync(path)) return fallback;
|
|
33
|
+
try {
|
|
34
|
+
const value = JSON.parse(readFileSync(path, 'utf8'));
|
|
35
|
+
return value && typeof value === 'object' ? value : fallback;
|
|
36
|
+
} catch {
|
|
37
|
+
return fallback;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function conversationIndexDir(ownerRoot) {
|
|
42
|
+
return join(ownerRoot, INDEX_DIR);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function conversationIndexScopeId(sessionId) {
|
|
46
|
+
return createHash('sha256').update(String(sessionId || ''), 'utf8').digest('hex').slice(0, 24);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function conversationIndexManifestPath(ownerRoot, sessionId) {
|
|
50
|
+
return join(conversationIndexDir(ownerRoot), 'manifests', `${conversationIndexScopeId(sessionId)}.json`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function conversationIndexDatabasePath(ownerRoot, sessionId, generation) {
|
|
54
|
+
return join(
|
|
55
|
+
conversationIndexDir(ownerRoot),
|
|
56
|
+
'databases',
|
|
57
|
+
`${conversationIndexScopeId(sessionId)}-${generation}.sqlite`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function removeConversationIndexScope(ownerRoot, sessionId) {
|
|
62
|
+
if (!ownerRoot || !sessionId) return;
|
|
63
|
+
const manifestPath = conversationIndexManifestPath(ownerRoot, sessionId);
|
|
64
|
+
const scopeId = conversationIndexScopeId(sessionId);
|
|
65
|
+
const databaseDir = join(conversationIndexDir(ownerRoot), 'databases');
|
|
66
|
+
rmSync(manifestPath, { force: true });
|
|
67
|
+
if (!existsSync(databaseDir)) return;
|
|
68
|
+
for (const name of readdirSync(databaseDir)) {
|
|
69
|
+
if (!name.startsWith(`${scopeId}-`) || !name.includes('.sqlite')) continue;
|
|
70
|
+
rmSync(join(databaseDir, name), { force: true });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function readConversationMutationState(ownerRoot) {
|
|
75
|
+
const cached = stateCache.get(ownerRoot);
|
|
76
|
+
if (cached) return cached;
|
|
77
|
+
const fallback = { version: STATE_VERSION, revision: 0, scopes: {} };
|
|
78
|
+
const value = safeJson(join(conversationIndexDir(ownerRoot), STATE_FILE), fallback);
|
|
79
|
+
const state = {
|
|
80
|
+
version: STATE_VERSION,
|
|
81
|
+
revision: Number(value.revision) || 0,
|
|
82
|
+
scopes: value.scopes && typeof value.scopes === 'object' ? value.scopes : {},
|
|
83
|
+
};
|
|
84
|
+
stateCache.set(ownerRoot, state);
|
|
85
|
+
return state;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function readConversationMutationInfo(ownerRoot, scopeKind, scopeId) {
|
|
89
|
+
const state = readConversationMutationState(ownerRoot);
|
|
90
|
+
const exact = state.scopes[scopeKey(scopeKind, scopeId)] || null;
|
|
91
|
+
const wildcard = state.scopes[scopeKey(scopeKind, '*')] || null;
|
|
92
|
+
const selected = (Number(exact?.revision) || 0) >= (Number(wildcard?.revision) || 0)
|
|
93
|
+
? exact
|
|
94
|
+
: wildcard;
|
|
95
|
+
return {
|
|
96
|
+
revision: Number(selected?.revision) || 0,
|
|
97
|
+
reason: selected?.reason || null,
|
|
98
|
+
at: selected?.at || null,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function readConversationMutationRevision(ownerRoot, scopeKind, scopeId) {
|
|
103
|
+
return readConversationMutationInfo(ownerRoot, scopeKind, scopeId).revision;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function flushConversationMutations(ownerRoot) {
|
|
107
|
+
const timer = flushTimers.get(ownerRoot);
|
|
108
|
+
if (timer) clearTimeout(timer);
|
|
109
|
+
flushTimers.delete(ownerRoot);
|
|
110
|
+
const eventMap = pendingEvents.get(ownerRoot) || new Map();
|
|
111
|
+
pendingEvents.delete(ownerRoot);
|
|
112
|
+
const events = Array.from(eventMap.values());
|
|
113
|
+
const state = stateCache.get(ownerRoot);
|
|
114
|
+
if (!state || events.length === 0) return;
|
|
115
|
+
const dir = conversationIndexDir(ownerRoot);
|
|
116
|
+
mkdirSync(dir, { recursive: true });
|
|
117
|
+
writeAtomic(join(dir, STATE_FILE), `${JSON.stringify(state, null, 2)}\n`);
|
|
118
|
+
const journalPath = join(dir, JOURNAL_FILE);
|
|
119
|
+
let journalBytes = 0;
|
|
120
|
+
try { journalBytes = statSync(journalPath).size; } catch {}
|
|
121
|
+
if (journalBytes >= MAX_JOURNAL_BYTES) writeAtomic(journalPath, '');
|
|
122
|
+
appendFileSync(journalPath, events.map(event => JSON.stringify(event)).join('\n') + '\n', {
|
|
123
|
+
encoding: 'utf8',
|
|
124
|
+
mode: 0o644,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function scheduleConversationMutationFlush(ownerRoot) {
|
|
129
|
+
if (flushTimers.has(ownerRoot)) return;
|
|
130
|
+
const timer = setTimeout(() => {
|
|
131
|
+
try { flushConversationMutations(ownerRoot); }
|
|
132
|
+
catch (error) { console.warn('[history-index] failed to flush mutation journal:', error?.message || error); }
|
|
133
|
+
}, FLUSH_DELAY_MS);
|
|
134
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
135
|
+
flushTimers.set(ownerRoot, timer);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Record one logical transcript mutation after the source write succeeds.
|
|
140
|
+
* Fingerprint reconciliation covers a crash before the coalesced journal flush,
|
|
141
|
+
* so this marker is a scheduling hint rather than a second authority.
|
|
142
|
+
*/
|
|
143
|
+
export function markConversationDirty({
|
|
144
|
+
ownerRoot,
|
|
145
|
+
scopeKind = 'session',
|
|
146
|
+
scopeId = '*',
|
|
147
|
+
reason = 'mutation',
|
|
148
|
+
sourceIds = null,
|
|
149
|
+
oldPath = null,
|
|
150
|
+
newPath = null,
|
|
151
|
+
} = {}) {
|
|
152
|
+
if (!ownerRoot) return null;
|
|
153
|
+
const state = readConversationMutationState(ownerRoot);
|
|
154
|
+
const revision = state.revision + 1;
|
|
155
|
+
const key = scopeKey(scopeKind, scopeId);
|
|
156
|
+
const event = {
|
|
157
|
+
version: STATE_VERSION,
|
|
158
|
+
revision,
|
|
159
|
+
scopeKind,
|
|
160
|
+
scopeId,
|
|
161
|
+
reason,
|
|
162
|
+
at: new Date().toISOString(),
|
|
163
|
+
...(Array.isArray(sourceIds) && sourceIds.length > 0 ? { sourceIds } : {}),
|
|
164
|
+
...(oldPath ? { oldPath } : {}),
|
|
165
|
+
...(newPath ? { newPath } : {}),
|
|
166
|
+
};
|
|
167
|
+
state.revision = revision;
|
|
168
|
+
state.scopes[key] = { revision, reason, at: event.at };
|
|
169
|
+
const events = pendingEvents.get(ownerRoot) || new Map();
|
|
170
|
+
events.set(key, event);
|
|
171
|
+
pendingEvents.set(ownerRoot, events);
|
|
172
|
+
scheduleConversationMutationFlush(ownerRoot);
|
|
173
|
+
return event;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function collectSourceFiles(root, out) {
|
|
177
|
+
if (!existsSync(root)) return;
|
|
178
|
+
let names;
|
|
179
|
+
try { names = readdirSync(root); } catch { return; }
|
|
180
|
+
names.sort();
|
|
181
|
+
for (const name of names) {
|
|
182
|
+
const path = join(root, name);
|
|
183
|
+
let stat;
|
|
184
|
+
try { stat = statSync(path); } catch { continue; }
|
|
185
|
+
if (stat.isDirectory()) {
|
|
186
|
+
if (name === 'compact' || name === 'blobs') continue;
|
|
187
|
+
collectSourceFiles(path, out);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const parentName = root.split(/[\\/]/).at(-1);
|
|
191
|
+
if (name.endsWith('.jsonl')
|
|
192
|
+
|| (name.endsWith('.md') && (parentName === 'messages' || parentName === 'cold'))) {
|
|
193
|
+
out.push(path);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function flushConversationIndexMutations(ownerRoot = null, { release = false } = {}) {
|
|
199
|
+
const roots = ownerRoot
|
|
200
|
+
? [ownerRoot]
|
|
201
|
+
: Array.from(new Set([...stateCache.keys(), ...pendingEvents.keys()]));
|
|
202
|
+
for (const root of roots) {
|
|
203
|
+
flushConversationMutations(root);
|
|
204
|
+
if (release) {
|
|
205
|
+
stateCache.delete(root);
|
|
206
|
+
pendingEvents.delete(root);
|
|
207
|
+
const timer = flushTimers.get(root);
|
|
208
|
+
if (timer) clearTimeout(timer);
|
|
209
|
+
flushTimers.delete(root);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function sourceStatKey(stat) {
|
|
215
|
+
return [
|
|
216
|
+
stat.dev,
|
|
217
|
+
stat.ino,
|
|
218
|
+
stat.size,
|
|
219
|
+
stat.mtimeNs,
|
|
220
|
+
stat.ctimeNs,
|
|
221
|
+
].join(':');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function stableFileDigest(path, digestCache, forceHash) {
|
|
225
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
226
|
+
const before = statSync(path, { bigint: true });
|
|
227
|
+
const key = sourceStatKey(before);
|
|
228
|
+
const cached = !forceHash ? digestCache?.get(path) : null;
|
|
229
|
+
if (cached?.key === key) return { digest: cached.digest, bytes: Number(before.size), key };
|
|
230
|
+
const hash = createHash('sha256');
|
|
231
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
232
|
+
const fd = openSync(path, 'r');
|
|
233
|
+
let bytes = 0;
|
|
234
|
+
try {
|
|
235
|
+
for (;;) {
|
|
236
|
+
const count = readSync(fd, buffer, 0, buffer.length, null);
|
|
237
|
+
if (count === 0) break;
|
|
238
|
+
hash.update(buffer.subarray(0, count));
|
|
239
|
+
bytes += count;
|
|
240
|
+
}
|
|
241
|
+
} finally {
|
|
242
|
+
closeSync(fd);
|
|
243
|
+
}
|
|
244
|
+
const after = statSync(path, { bigint: true });
|
|
245
|
+
if (sourceStatKey(after) !== key) continue;
|
|
246
|
+
const digest = hash.digest('hex');
|
|
247
|
+
digestCache?.set(path, { key, digest });
|
|
248
|
+
return { digest, bytes, key };
|
|
249
|
+
}
|
|
250
|
+
const error = new Error(`history source changed while hashing: ${path}`);
|
|
251
|
+
error.code = 'source_changed';
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function fingerprintConversationSources(ownerRoot, sessionId, {
|
|
256
|
+
digestCache = null,
|
|
257
|
+
forceHash = false,
|
|
258
|
+
} = {}) {
|
|
259
|
+
const safeSessionId = String(sessionId || '')
|
|
260
|
+
.replace(/[^A-Za-z0-9._-]/g, '_')
|
|
261
|
+
.slice(0, 120)
|
|
262
|
+
.replace(/^\.+$/, '_') || '_';
|
|
263
|
+
const roots = [
|
|
264
|
+
join(ownerRoot, 'sessions', safeSessionId, 'conversation'),
|
|
265
|
+
// Legacy on-disk alias retained for pre-Session transcript compatibility.
|
|
266
|
+
join(ownerRoot, 'groups', safeSessionId, 'conversation'),
|
|
267
|
+
];
|
|
268
|
+
const files = [];
|
|
269
|
+
for (const root of roots) collectSourceFiles(root, files);
|
|
270
|
+
files.sort();
|
|
271
|
+
const hash = createHash('sha256');
|
|
272
|
+
hash.update(`history-source-v${STATE_VERSION}\0${sessionId}\0`, 'utf8');
|
|
273
|
+
let bytes = 0;
|
|
274
|
+
const livePaths = new Set(files);
|
|
275
|
+
for (const path of files) {
|
|
276
|
+
const file = stableFileDigest(path, digestCache, forceHash);
|
|
277
|
+
bytes += file.bytes;
|
|
278
|
+
hash.update(relative(ownerRoot, path), 'utf8');
|
|
279
|
+
hash.update('\0', 'utf8');
|
|
280
|
+
hash.update(file.digest, 'ascii');
|
|
281
|
+
hash.update('\0', 'utf8');
|
|
282
|
+
}
|
|
283
|
+
if (digestCache) {
|
|
284
|
+
for (const path of digestCache.keys()) {
|
|
285
|
+
if (!livePaths.has(path)) digestCache.delete(path);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
fingerprint: hash.digest('hex'),
|
|
290
|
+
files: files.length,
|
|
291
|
+
bytes,
|
|
292
|
+
exists: files.length > 0,
|
|
293
|
+
};
|
|
294
|
+
}
|