@yeaft/webchat-agent 0.1.399 → 0.1.409

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,269 @@
1
+ /**
2
+ * config.js — Yeaft configuration management
3
+ *
4
+ * Priority (high → low): CLI overrides > ENV vars > .env file > config.md frontmatter > defaults
5
+ *
6
+ * Note: "model" in Yeaft always means a model ID (e.g. "gpt-5", "claude-sonnet-4-20250514").
7
+ * Yeaft does not provide its own models — it routes to external LLM providers via adapters.
8
+ */
9
+
10
+ import { existsSync, readFileSync } from 'fs';
11
+ import { join } from 'path';
12
+ import { DEFAULT_YEAFT_DIR } from './init.js';
13
+ import { resolveModel } from './models.js';
14
+
15
+ /** Default configuration values. */
16
+ const DEFAULTS = {
17
+ model: 'claude-sonnet-4-20250514',
18
+ fallbackModel: null,
19
+ language: 'en', // 'en' | 'zh'
20
+ apiKey: null,
21
+ openaiApiKey: null,
22
+ proxyUrl: 'http://localhost:6628',
23
+ baseUrl: null,
24
+ adapter: null, // auto-detect: 'anthropic' | 'openai' | 'proxy'
25
+ debug: false,
26
+ dir: DEFAULT_YEAFT_DIR,
27
+ maxContextTokens: 200000,
28
+ maxOutputTokens: 16384,
29
+ messageTokenBudget: 8192, // Phase 2: context * 4%, triggers consolidation
30
+ maxContinueTurns: 3, // Phase 2: auto-continue on max_tokens
31
+ };
32
+
33
+ /**
34
+ * Parse YAML frontmatter from a markdown file.
35
+ * Simple parser — handles key: value pairs, no nested objects.
36
+ *
37
+ * @param {string} content — File content
38
+ * @returns {Record<string, string>} — Parsed frontmatter
39
+ */
40
+ export function parseFrontmatter(content) {
41
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
42
+ if (!match) return {};
43
+
44
+ const result = {};
45
+ for (const line of match[1].split('\n')) {
46
+ const trimmed = line.trim();
47
+ if (!trimmed || trimmed.startsWith('#')) continue;
48
+ const colonIdx = trimmed.indexOf(':');
49
+ if (colonIdx === -1) continue;
50
+ const key = trimmed.slice(0, colonIdx).trim();
51
+ let value = trimmed.slice(colonIdx + 1).trim();
52
+ // Parse booleans and numbers
53
+ if (value === 'true') value = true;
54
+ else if (value === 'false') value = false;
55
+ else if (value === 'null') value = null;
56
+ else if (/^\d+$/.test(value)) value = parseInt(value, 10);
57
+ result[key] = value;
58
+ }
59
+ return result;
60
+ }
61
+
62
+ /**
63
+ * Load configuration from config.md file.
64
+ *
65
+ * @param {string} dir — Yeaft data directory
66
+ * @returns {Record<string, unknown>} — Config from file
67
+ */
68
+ function loadConfigFile(dir) {
69
+ const configPath = join(dir, 'config.md');
70
+ if (!existsSync(configPath)) return {};
71
+
72
+ try {
73
+ const content = readFileSync(configPath, 'utf8');
74
+ return parseFrontmatter(content);
75
+ } catch {
76
+ return {};
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Load .env file from a directory. Sets process.env for any keys
82
+ * not already defined (env vars take precedence over .env file).
83
+ *
84
+ * ⚠️ Side-effect: mutates process.env globally. Values set by a previous
85
+ * call persist across subsequent loadConfig() calls within the same process.
86
+ * This is by design (matches dotenv behavior), but callers that need isolation
87
+ * (e.g. tests) must manually delete keys from process.env between calls.
88
+ *
89
+ * @param {string} dir — Directory containing .env file
90
+ */
91
+ function loadEnvFile(dir) {
92
+ const envPath = join(dir, '.env');
93
+ if (!existsSync(envPath)) return;
94
+
95
+ try {
96
+ const content = readFileSync(envPath, 'utf8');
97
+ for (const line of content.split('\n')) {
98
+ let trimmed = line.trim();
99
+ // Skip empty lines and comments
100
+ if (!trimmed || trimmed.startsWith('#')) continue;
101
+ // Strip optional 'export ' prefix (common in .env files)
102
+ if (trimmed.startsWith('export ')) trimmed = trimmed.slice(7);
103
+ const eqIdx = trimmed.indexOf('=');
104
+ if (eqIdx === -1) continue;
105
+
106
+ const key = trimmed.slice(0, eqIdx).trim();
107
+ let value = trimmed.slice(eqIdx + 1).trim();
108
+ // Remove surrounding quotes if present
109
+ if ((value.startsWith('"') && value.endsWith('"')) ||
110
+ (value.startsWith("'") && value.endsWith("'"))) {
111
+ value = value.slice(1, -1);
112
+ }
113
+
114
+ // Only set if not already defined (shell env takes precedence)
115
+ if (process.env[key] === undefined) {
116
+ process.env[key] = value;
117
+ }
118
+ }
119
+ } catch {
120
+ // Silently ignore .env read errors
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Helper: check if an env var is truthy.
126
+ * @param {string|undefined} val
127
+ * @returns {boolean}
128
+ */
129
+ function isTruthy(val) {
130
+ return val === '1' || val === 'true' || val === 'yes';
131
+ }
132
+
133
+ /**
134
+ * Load full configuration.
135
+ *
136
+ * @param {Record<string, unknown>} [overrides] — CLI overrides
137
+ * @returns {object} — Merged configuration
138
+ */
139
+ export function loadConfig(overrides = {}) {
140
+ const env = process.env;
141
+
142
+ // Determine data directory first (needed to load .env and config.md)
143
+ const dir = overrides.dir || env.YEAFT_DIR || DEFAULTS.dir;
144
+
145
+ // Load .env file (sets process.env for undefined keys — shell env takes precedence)
146
+ loadEnvFile(dir);
147
+
148
+ // Load from config.md
149
+ const fileConfig = loadConfigFile(dir);
150
+
151
+ // Build merged config: defaults < file < env < overrides
152
+ const config = {
153
+ model:
154
+ overrides.model ||
155
+ env.YEAFT_MODEL ||
156
+ fileConfig.model ||
157
+ DEFAULTS.model,
158
+
159
+ fallbackModel:
160
+ overrides.fallbackModel ||
161
+ env.YEAFT_FALLBACK_MODEL ||
162
+ fileConfig.fallbackModel ||
163
+ DEFAULTS.fallbackModel,
164
+
165
+ language:
166
+ overrides.language ||
167
+ env.YEAFT_LANGUAGE ||
168
+ fileConfig.language ||
169
+ DEFAULTS.language,
170
+
171
+ apiKey:
172
+ overrides.apiKey ||
173
+ env.YEAFT_API_KEY ||
174
+ fileConfig.apiKey ||
175
+ DEFAULTS.apiKey,
176
+
177
+ openaiApiKey:
178
+ overrides.openaiApiKey ||
179
+ env.YEAFT_OPENAI_API_KEY ||
180
+ fileConfig.openaiApiKey ||
181
+ DEFAULTS.openaiApiKey,
182
+
183
+ proxyUrl:
184
+ overrides.proxyUrl ||
185
+ env.YEAFT_PROXY_URL ||
186
+ fileConfig.proxyUrl ||
187
+ DEFAULTS.proxyUrl,
188
+
189
+ baseUrl:
190
+ overrides.baseUrl ||
191
+ env.YEAFT_BASE_URL ||
192
+ fileConfig.baseUrl ||
193
+ DEFAULTS.baseUrl,
194
+
195
+ adapter:
196
+ overrides.adapter ||
197
+ env.YEAFT_ADAPTER ||
198
+ fileConfig.adapter ||
199
+ DEFAULTS.adapter,
200
+
201
+ debug:
202
+ overrides.debug !== undefined
203
+ ? overrides.debug
204
+ : env.YEAFT_DEBUG !== undefined
205
+ ? isTruthy(env.YEAFT_DEBUG)
206
+ : fileConfig.debug !== undefined
207
+ ? fileConfig.debug
208
+ : DEFAULTS.debug,
209
+
210
+ dir,
211
+
212
+ maxContextTokens:
213
+ overrides.maxContextTokens ??
214
+ (env.YEAFT_MAX_CONTEXT ? parseInt(env.YEAFT_MAX_CONTEXT, 10) : null) ??
215
+ fileConfig.maxContextTokens ??
216
+ DEFAULTS.maxContextTokens,
217
+
218
+ maxOutputTokens:
219
+ overrides.maxOutputTokens ??
220
+ fileConfig.maxOutputTokens ??
221
+ DEFAULTS.maxOutputTokens,
222
+
223
+ messageTokenBudget:
224
+ overrides.messageTokenBudget ??
225
+ (env.YEAFT_MESSAGE_TOKEN_BUDGET ? parseInt(env.YEAFT_MESSAGE_TOKEN_BUDGET, 10) : null) ??
226
+ fileConfig.messageTokenBudget ??
227
+ DEFAULTS.messageTokenBudget,
228
+
229
+ maxContinueTurns:
230
+ overrides.maxContinueTurns ??
231
+ fileConfig.maxContinueTurns ??
232
+ DEFAULTS.maxContinueTurns,
233
+ };
234
+
235
+ // Auto-detect adapter using model registry + credential fallback
236
+ if (!config.adapter) {
237
+ const modelInfo = resolveModel(config.model);
238
+ if (modelInfo) {
239
+ // Known model → set adapter from registry
240
+ config.adapter = modelInfo.adapter === 'anthropic' ? 'anthropic' : 'openai';
241
+ // Use registry baseUrl if not explicitly overridden
242
+ if (!config.baseUrl) {
243
+ config.baseUrl = modelInfo.baseUrl;
244
+ }
245
+ // Use registry contextWindow if still at default
246
+ if (config.maxContextTokens === DEFAULTS.maxContextTokens) {
247
+ config.maxContextTokens = modelInfo.contextWindow;
248
+ }
249
+ // Use registry maxOutputTokens if still at default
250
+ if (config.maxOutputTokens === DEFAULTS.maxOutputTokens) {
251
+ config.maxOutputTokens = modelInfo.maxOutputTokens;
252
+ }
253
+ } else {
254
+ // Unknown model → fallback to credential-based detection
255
+ if (config.apiKey) {
256
+ config.adapter = 'anthropic';
257
+ } else if (config.openaiApiKey) {
258
+ config.adapter = 'openai';
259
+ } else if (config.proxyUrl) {
260
+ config.adapter = 'proxy';
261
+ }
262
+ }
263
+ }
264
+
265
+ // Store resolved model info for reference
266
+ config.modelInfo = resolveModel(config.model) || null;
267
+
268
+ return config;
269
+ }
@@ -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
+ }