@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.
- package/package.json +1 -1
- package/unify/cli.js +214 -16
- package/unify/config.js +13 -0
- package/unify/conversation/persist.js +436 -0
- package/unify/conversation/search.js +65 -0
- package/unify/engine.js +210 -18
- package/unify/index.js +18 -0
- package/unify/mcp.js +433 -0
- package/unify/memory/consolidate.js +187 -0
- package/unify/memory/dream-prompt.js +272 -0
- package/unify/memory/dream.js +468 -0
- package/unify/memory/extract.js +97 -0
- package/unify/memory/recall.js +243 -0
- package/unify/memory/scan.js +273 -0
- package/unify/memory/store.js +507 -0
- package/unify/memory/types.js +139 -0
- package/unify/prompts.js +51 -3
- package/unify/skills.js +315 -0
- package/unify/stop-hooks.js +146 -0
- package/unify/tools/enter-worktree.js +97 -0
- package/unify/tools/exit-worktree.js +131 -0
- package/unify/tools/mcp-tools.js +133 -0
- package/unify/tools/registry.js +146 -0
- package/unify/tools/skill.js +107 -0
- package/unify/tools/types.js +71 -0
|
@@ -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
|
+
}
|