@yeaft/webchat-agent 0.1.408 → 0.1.410

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.
@@ -0,0 +1,436 @@
1
+ /**
2
+ * persist.js — Conversation message persistence
3
+ *
4
+ * Each message is stored as a .md file with YAML frontmatter in
5
+ * ~/.yeaft/conversation/messages/. Design: zero JSON, all Markdown.
6
+ *
7
+ * Message format:
8
+ * ---
9
+ * id: m0355
10
+ * role: user
11
+ * time: 2026-04-09T14:35:00Z
12
+ * mode: chat
13
+ * model: claude-sonnet-4-20250514
14
+ * tokens_est: 230
15
+ * ---
16
+ * Message content here...
17
+ *
18
+ * Reference: yeaft-unify-core-systems.md §4.1, yeaft-unify-brainstorm-v5.1.md
19
+ */
20
+
21
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync } from 'fs';
22
+ import { join, basename } from 'path';
23
+
24
+ // ─── Token estimation ────────────────────────────────────────
25
+
26
+ /** Rough token estimation: ~4 chars per token. */
27
+ export function estimateTokens(text) {
28
+ if (!text) return 0;
29
+ return Math.ceil(text.length / 4);
30
+ }
31
+
32
+ // ─── Frontmatter helpers ─────────────────────────────────────
33
+
34
+ /**
35
+ * Serialize message metadata to YAML frontmatter + body.
36
+ * @param {object} msg
37
+ * @returns {string}
38
+ */
39
+ function serializeMessage(msg) {
40
+ const fm = [
41
+ '---',
42
+ `id: ${msg.id}`,
43
+ `role: ${msg.role}`,
44
+ `time: ${msg.time || new Date().toISOString()}`,
45
+ ];
46
+
47
+ if (msg.mode) fm.push(`mode: ${msg.mode}`);
48
+ if (msg.model) fm.push(`model: ${msg.model}`);
49
+ if (msg.turnNumber != null) fm.push(`turnNumber: ${msg.turnNumber}`);
50
+ if (msg.toolCallId) fm.push(`toolCallId: ${msg.toolCallId}`);
51
+ if (msg.isError) fm.push(`isError: true`);
52
+
53
+ // Token estimate
54
+ const content = msg.content || '';
55
+ const tokensEst = msg.tokens_est || estimateTokens(content);
56
+ fm.push(`tokens_est: ${tokensEst}`);
57
+
58
+ // Tool calls as YAML array (simplified)
59
+ if (msg.toolCalls && msg.toolCalls.length > 0) {
60
+ fm.push(`toolCalls:`);
61
+ for (const tc of msg.toolCalls) {
62
+ fm.push(` - id: ${tc.id}`);
63
+ fm.push(` name: ${tc.name}`);
64
+ }
65
+ }
66
+
67
+ fm.push('---');
68
+ fm.push('');
69
+ fm.push(content);
70
+
71
+ return fm.join('\n');
72
+ }
73
+
74
+ /**
75
+ * Parse a message .md file into a message object.
76
+ * @param {string} raw — Raw file content
77
+ * @returns {object|null}
78
+ */
79
+ export function parseMessage(raw) {
80
+ if (!raw || !raw.startsWith('---')) return null;
81
+
82
+ const endIdx = raw.indexOf('\n---', 3);
83
+ if (endIdx === -1) return null;
84
+
85
+ const frontmatter = raw.slice(4, endIdx).trim();
86
+ const body = raw.slice(endIdx + 4).trim();
87
+
88
+ const msg = { content: body };
89
+
90
+ for (const line of frontmatter.split('\n')) {
91
+ const colonIdx = line.indexOf(':');
92
+ if (colonIdx === -1) continue;
93
+
94
+ const key = line.slice(0, colonIdx).trim();
95
+ const value = line.slice(colonIdx + 1).trim();
96
+
97
+ switch (key) {
98
+ case 'id': msg.id = value; break;
99
+ case 'role': msg.role = value; break;
100
+ case 'time': msg.time = value; break;
101
+ case 'mode': msg.mode = value; break;
102
+ case 'model': msg.model = value; break;
103
+ case 'turnNumber': msg.turnNumber = parseInt(value, 10); break;
104
+ case 'toolCallId': msg.toolCallId = value; break;
105
+ case 'isError': msg.isError = value === 'true'; break;
106
+ case 'tokens_est': msg.tokens_est = parseInt(value, 10); break;
107
+ // toolCalls are multi-line YAML — handled separately below
108
+ }
109
+ }
110
+
111
+ // Parse toolCalls if present (simplified multi-line YAML)
112
+ if (frontmatter.includes('toolCalls:')) {
113
+ const toolCalls = [];
114
+ const tcMatch = frontmatter.match(/toolCalls:\n((?:\s+-\s+[\s\S]*?)(?=\n\w|$))/);
115
+ if (tcMatch) {
116
+ const tcBlock = tcMatch[1];
117
+ const entries = tcBlock.split(/\n\s+-\s+/).filter(Boolean);
118
+ for (const entry of entries) {
119
+ const tc = {};
120
+ for (const line of entry.split('\n')) {
121
+ const trimmed = line.trim();
122
+ const ci = trimmed.indexOf(':');
123
+ if (ci === -1) continue;
124
+ const k = trimmed.slice(0, ci).trim();
125
+ const v = trimmed.slice(ci + 1).trim();
126
+ if (k === 'id') tc.id = v;
127
+ if (k === 'name') tc.name = v;
128
+ }
129
+ if (tc.id && tc.name) toolCalls.push(tc);
130
+ }
131
+ }
132
+ if (toolCalls.length > 0) msg.toolCalls = toolCalls;
133
+ }
134
+
135
+ return msg;
136
+ }
137
+
138
+ // ─── ConversationStore ───────────────────────────────────────
139
+
140
+ /**
141
+ * ConversationStore — persist and load messages to/from disk.
142
+ *
143
+ * Directory layout:
144
+ * conversation/
145
+ * index.md — message index with frontmatter
146
+ * compact.md — cumulative compact summary
147
+ * messages/ — hot messages (mNNNN.md)
148
+ * cold/ — archived messages (moved from messages/)
149
+ * blobs/ — attachments (never moved)
150
+ */
151
+ export class ConversationStore {
152
+ #dir; // root dir (e.g. ~/.yeaft)
153
+ #convDir; // ~/.yeaft/conversation
154
+ #msgDir; // ~/.yeaft/conversation/messages
155
+ #coldDir; // ~/.yeaft/conversation/cold
156
+ #indexPath; // ~/.yeaft/conversation/index.md
157
+ #compactPath; // ~/.yeaft/conversation/compact.md
158
+ #nextSeq; // next message sequence number
159
+
160
+ /**
161
+ * @param {string} dir — Yeaft root directory (e.g. ~/.yeaft)
162
+ */
163
+ constructor(dir) {
164
+ this.#dir = dir;
165
+ this.#convDir = join(dir, 'conversation');
166
+ this.#msgDir = join(dir, 'conversation', 'messages');
167
+ this.#coldDir = join(dir, 'conversation', 'cold');
168
+ this.#indexPath = join(dir, 'conversation', 'index.md');
169
+ this.#compactPath = join(dir, 'conversation', 'compact.md');
170
+ this.#nextSeq = null;
171
+
172
+ // Ensure directories exist
173
+ for (const d of [this.#convDir, this.#msgDir, this.#coldDir]) {
174
+ if (!existsSync(d)) mkdirSync(d, { recursive: true });
175
+ }
176
+ }
177
+
178
+ // ─── Write API ──────────────────────────────────────────
179
+
180
+ /**
181
+ * Append a single message to the conversation.
182
+ *
183
+ * @param {object} msg — { role, content, mode?, model?, turnNumber?, toolCalls?, toolCallId?, isError? }
184
+ * @returns {object} — the persisted message with id assigned
185
+ */
186
+ append(msg) {
187
+ const seq = this.#getNextSeq();
188
+ const id = `m${String(seq).padStart(4, '0')}`;
189
+ const fullMsg = {
190
+ ...msg,
191
+ id,
192
+ time: msg.time || new Date().toISOString(),
193
+ tokens_est: msg.tokens_est || estimateTokens(msg.content || ''),
194
+ };
195
+
196
+ const filePath = join(this.#msgDir, `${id}.md`);
197
+ writeFileSync(filePath, serializeMessage(fullMsg), 'utf8');
198
+
199
+ this.#nextSeq = seq + 1;
200
+
201
+ return fullMsg;
202
+ }
203
+
204
+ /**
205
+ * Append multiple messages at once.
206
+ *
207
+ * @param {object[]} messages
208
+ * @returns {object[]} — persisted messages with ids
209
+ */
210
+ appendBatch(messages) {
211
+ return messages.map(m => this.append(m));
212
+ }
213
+
214
+ /**
215
+ * Move a message from hot (messages/) to cold (cold/).
216
+ *
217
+ * @param {string} id — message id (e.g. "m0355")
218
+ */
219
+ moveToCold(id) {
220
+ const src = join(this.#msgDir, `${id}.md`);
221
+ const dst = join(this.#coldDir, `${id}.md`);
222
+ if (existsSync(src)) {
223
+ renameSync(src, dst);
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Move multiple messages to cold.
229
+ *
230
+ * @param {string[]} ids
231
+ */
232
+ moveToColdBatch(ids) {
233
+ for (const id of ids) {
234
+ this.moveToCold(id);
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Update the compact summary (cumulative).
240
+ *
241
+ * @param {string} summary — new summary to append
242
+ */
243
+ updateCompactSummary(summary) {
244
+ let existing = '';
245
+ if (existsSync(this.#compactPath)) {
246
+ existing = readFileSync(this.#compactPath, 'utf8');
247
+ }
248
+
249
+ const date = new Date().toISOString().split('T')[0];
250
+ const entry = `\n## ${date}\n\n${summary}\n`;
251
+ writeFileSync(this.#compactPath, existing + entry, 'utf8');
252
+ }
253
+
254
+ /**
255
+ * Read the compact summary.
256
+ *
257
+ * @returns {string}
258
+ */
259
+ readCompactSummary() {
260
+ if (!existsSync(this.#compactPath)) return '';
261
+ return readFileSync(this.#compactPath, 'utf8');
262
+ }
263
+
264
+ /**
265
+ * Update the conversation index.md with current state.
266
+ *
267
+ * @param {{ totalMessages?: number, lastMessageId?: string }} info
268
+ */
269
+ updateIndex(info = {}) {
270
+ const total = info.totalMessages ?? this.countHot() + this.countCold();
271
+ const lastId = info.lastMessageId ?? null;
272
+ const lastAccessed = new Date().toISOString();
273
+
274
+ const content = [
275
+ '---',
276
+ `lastMessageId: ${lastId || 'null'}`,
277
+ `totalMessages: ${total}`,
278
+ `hotMessages: ${this.countHot()}`,
279
+ `coldMessages: ${this.countCold()}`,
280
+ `lastAccessed: ${lastAccessed}`,
281
+ '---',
282
+ '',
283
+ '# Conversation Index',
284
+ '',
285
+ 'This file tracks the conversation state for the "one eternal conversation" model.',
286
+ ].join('\n');
287
+
288
+ writeFileSync(this.#indexPath, content, 'utf8');
289
+ }
290
+
291
+ /**
292
+ * Clear all messages (hot + cold + compact).
293
+ */
294
+ clear() {
295
+ for (const dir of [this.#msgDir, this.#coldDir]) {
296
+ if (existsSync(dir)) {
297
+ for (const file of readdirSync(dir)) {
298
+ if (file.endsWith('.md')) {
299
+ unlinkSync(join(dir, file));
300
+ }
301
+ }
302
+ }
303
+ }
304
+ // Reset compact
305
+ if (existsSync(this.#compactPath)) {
306
+ writeFileSync(this.#compactPath, '', 'utf8');
307
+ }
308
+ this.#nextSeq = 1;
309
+ this.updateIndex({ totalMessages: 0, lastMessageId: null });
310
+ }
311
+
312
+ // ─── Read API ───────────────────────────────────────────
313
+
314
+ /**
315
+ * Load recent hot messages, sorted by id (chronological).
316
+ *
317
+ * @param {number} [limit=50] — max messages to load
318
+ * @returns {object[]} — parsed message objects
319
+ */
320
+ loadRecent(limit = 50) {
321
+ return this.#loadFromDir(this.#msgDir, limit);
322
+ }
323
+
324
+ /**
325
+ * Load all hot messages.
326
+ *
327
+ * @returns {object[]}
328
+ */
329
+ loadAll() {
330
+ return this.#loadFromDir(this.#msgDir, Infinity);
331
+ }
332
+
333
+ /**
334
+ * Count hot messages.
335
+ *
336
+ * @returns {number}
337
+ */
338
+ countHot() {
339
+ if (!existsSync(this.#msgDir)) return 0;
340
+ return readdirSync(this.#msgDir).filter(f => f.endsWith('.md')).length;
341
+ }
342
+
343
+ /**
344
+ * Count cold messages.
345
+ *
346
+ * @returns {number}
347
+ */
348
+ countCold() {
349
+ if (!existsSync(this.#coldDir)) return 0;
350
+ return readdirSync(this.#coldDir).filter(f => f.endsWith('.md')).length;
351
+ }
352
+
353
+ /**
354
+ * Get total estimated tokens for hot messages.
355
+ *
356
+ * @returns {number}
357
+ */
358
+ hotTokens() {
359
+ const messages = this.loadAll();
360
+ return messages.reduce((sum, m) => sum + (m.tokens_est || estimateTokens(m.content || '')), 0);
361
+ }
362
+
363
+ /**
364
+ * Read the conversation index.
365
+ *
366
+ * @returns {object}
367
+ */
368
+ readIndex() {
369
+ if (!existsSync(this.#indexPath)) {
370
+ return { lastMessageId: null, totalMessages: 0, hotMessages: 0, coldMessages: 0 };
371
+ }
372
+ const raw = readFileSync(this.#indexPath, 'utf8');
373
+ const parsed = parseMessage(raw);
374
+ if (!parsed) {
375
+ return { lastMessageId: null, totalMessages: 0, hotMessages: 0, coldMessages: 0 };
376
+ }
377
+ // Re-parse from frontmatter fields
378
+ return {
379
+ lastMessageId: parsed.id || null,
380
+ totalMessages: parsed.tokens_est || 0, // reuse field parsing
381
+ };
382
+ }
383
+
384
+ // ─── Internal ───────────────────────────────────────────
385
+
386
+ /**
387
+ * Load messages from a directory, sorted by filename, limited.
388
+ * @param {string} dir
389
+ * @param {number} limit
390
+ * @returns {object[]}
391
+ */
392
+ #loadFromDir(dir, limit) {
393
+ if (!existsSync(dir)) return [];
394
+
395
+ const files = readdirSync(dir)
396
+ .filter(f => f.endsWith('.md'))
397
+ .sort(); // m0001.md < m0002.md — chronological
398
+
399
+ // Take the most recent `limit` files
400
+ const selected = limit < Infinity
401
+ ? files.slice(-limit)
402
+ : files;
403
+
404
+ const messages = [];
405
+ for (const file of selected) {
406
+ const raw = readFileSync(join(dir, file), 'utf8');
407
+ const parsed = parseMessage(raw);
408
+ if (parsed) messages.push(parsed);
409
+ }
410
+
411
+ return messages;
412
+ }
413
+
414
+ /**
415
+ * Determine the next sequence number by scanning existing files.
416
+ * @returns {number}
417
+ */
418
+ #getNextSeq() {
419
+ if (this.#nextSeq != null) return this.#nextSeq;
420
+
421
+ let maxSeq = 0;
422
+ for (const dir of [this.#msgDir, this.#coldDir]) {
423
+ if (!existsSync(dir)) continue;
424
+ for (const file of readdirSync(dir)) {
425
+ const match = file.match(/^m(\d+)\.md$/);
426
+ if (match) {
427
+ const seq = parseInt(match[1], 10);
428
+ if (seq > maxSeq) maxSeq = seq;
429
+ }
430
+ }
431
+ }
432
+
433
+ this.#nextSeq = maxSeq + 1;
434
+ return this.#nextSeq;
435
+ }
436
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * search.js — Conversation history search
3
+ *
4
+ * Simple keyword search across hot and cold messages.
5
+ * Reference: yeaft-unify-core-systems.md §4.3
6
+ */
7
+
8
+ import { existsSync, readdirSync, readFileSync } from 'fs';
9
+ import { join } from 'path';
10
+ import { parseMessage } from './persist.js';
11
+
12
+ /**
13
+ * Search messages in a directory for a keyword.
14
+ *
15
+ * @param {string} dir — messages directory
16
+ * @param {string} keyword — search term (case-insensitive)
17
+ * @param {number} limit — max results
18
+ * @returns {object[]} — matching messages
19
+ */
20
+ function searchDir(dir, keyword, limit) {
21
+ if (!existsSync(dir)) return [];
22
+
23
+ const lowerKeyword = keyword.toLowerCase();
24
+ const files = readdirSync(dir)
25
+ .filter(f => f.endsWith('.md'))
26
+ .sort()
27
+ .reverse(); // newest first
28
+
29
+ const results = [];
30
+ for (const file of files) {
31
+ if (results.length >= limit) break;
32
+
33
+ const raw = readFileSync(join(dir, file), 'utf8');
34
+ if (raw.toLowerCase().includes(lowerKeyword)) {
35
+ const msg = parseMessage(raw);
36
+ if (msg) results.push(msg);
37
+ }
38
+ }
39
+
40
+ return results;
41
+ }
42
+
43
+ /**
44
+ * Search conversation history (hot + cold) for a keyword.
45
+ *
46
+ * @param {string} dir — Yeaft root directory (e.g. ~/.yeaft)
47
+ * @param {string} keyword — search term
48
+ * @param {number} [limit=20] — max results
49
+ * @returns {object[]} — matching messages, newest first
50
+ */
51
+ export function searchMessages(dir, keyword, limit = 20) {
52
+ if (!keyword || !keyword.trim()) return [];
53
+
54
+ const msgDir = join(dir, 'conversation', 'messages');
55
+ const coldDir = join(dir, 'conversation', 'cold');
56
+
57
+ // Search hot first (more recent), then cold
58
+ const hotResults = searchDir(msgDir, keyword, limit);
59
+ const remaining = limit - hotResults.length;
60
+ const coldResults = remaining > 0
61
+ ? searchDir(coldDir, keyword, remaining)
62
+ : [];
63
+
64
+ return [...hotResults, ...coldResults];
65
+ }