@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,511 @@
1
+ /**
2
+ * engine.js — Yeaft query loop
3
+ *
4
+ * The engine is the core orchestrator:
5
+ * 1. Before first turn: recall memories → inject into system prompt
6
+ * 2. Build messages array (with compact summary if available)
7
+ * 3. Call adapter.stream()
8
+ * 4. Collect text + tool_calls from stream events
9
+ * 5. If tool_calls → execute tools → append results → goto 3
10
+ * 6. If end_turn → persist messages → check consolidation → done
11
+ * 7. If max_tokens → auto-continue (up to maxContinueTurns)
12
+ * 8. On LLMContextError → force compact → retry
13
+ * 9. On retryable error with fallbackModel → switch model → retry
14
+ *
15
+ * Pattern derived from Claude Code's query loop (src/query.ts).
16
+ *
17
+ * Reference: yeaft-unify-implementation-plan.md §3.1, §4 (Phase 2)
18
+ */
19
+
20
+ import { randomUUID } from 'crypto';
21
+ import { buildSystemPrompt } from './prompts.js';
22
+ import { LLMContextError } from './llm/adapter.js';
23
+ import { recall } from './memory/recall.js';
24
+ import { shouldConsolidate, consolidate } from './memory/consolidate.js';
25
+
26
+ /** Maximum number of turns before the engine stops to prevent infinite loops. */
27
+ const MAX_TURNS = 25;
28
+
29
+ /** Maximum auto-continue turns when stopReason is 'max_tokens'. */
30
+ const MAX_CONTINUE_TURNS = 3;
31
+
32
+ // ─── Engine Events (superset of adapter events) ──────────────────
33
+
34
+ /**
35
+ * @typedef {{ type: 'turn_start', turnNumber: number }} TurnStartEvent
36
+ * @typedef {{ type: 'turn_end', turnNumber: number, stopReason: string }} TurnEndEvent
37
+ * @typedef {{ type: 'tool_start', id: string, name: string, input: object }} ToolStartEvent
38
+ * @typedef {{ type: 'tool_end', id: string, name: string, output: string, isError: boolean }} ToolEndEvent
39
+ * @typedef {{ type: 'consolidate', archivedCount: number, extractedCount: number }} ConsolidateEvent
40
+ * @typedef {{ type: 'recall', entryCount: number, cached: boolean }} RecallEvent
41
+ * @typedef {{ type: 'fallback', from: string, to: string, reason: string }} FallbackEvent
42
+ *
43
+ * @typedef {import('./llm/adapter.js').StreamEvent | TurnStartEvent | TurnEndEvent | ToolStartEvent | ToolEndEvent | ConsolidateEvent | RecallEvent | FallbackEvent} EngineEvent
44
+ */
45
+
46
+ // ─── Engine ──────────────────────────────────────────────────────
47
+
48
+ export class Engine {
49
+ /** @type {import('./llm/adapter.js').LLMAdapter} */
50
+ #adapter;
51
+
52
+ /** @type {import('./debug-trace.js').DebugTrace | import('./debug-trace.js').NullTrace} */
53
+ #trace;
54
+
55
+ /** @type {object} */
56
+ #config;
57
+
58
+ /** @type {Map<string, { name: string, description: string, parameters: object, execute: function }>} */
59
+ #tools;
60
+
61
+ /** @type {string} */
62
+ #traceId;
63
+
64
+ /** @type {import('./conversation/persist.js').ConversationStore|null} */
65
+ #conversationStore;
66
+
67
+ /** @type {import('./memory/store.js').MemoryStore|null} */
68
+ #memoryStore;
69
+
70
+ /**
71
+ * @param {{
72
+ * adapter: import('./llm/adapter.js').LLMAdapter,
73
+ * trace: object,
74
+ * config: object,
75
+ * conversationStore?: import('./conversation/persist.js').ConversationStore,
76
+ * memoryStore?: import('./memory/store.js').MemoryStore
77
+ * }} params
78
+ */
79
+ constructor({ adapter, trace, config, conversationStore, memoryStore }) {
80
+ this.#adapter = adapter;
81
+ this.#trace = trace;
82
+ this.#config = config;
83
+ this.#tools = new Map();
84
+ this.#traceId = randomUUID();
85
+ this.#conversationStore = conversationStore || null;
86
+ this.#memoryStore = memoryStore || null;
87
+ }
88
+
89
+ /**
90
+ * Register a tool that the LLM can call.
91
+ *
92
+ * @param {{ name: string, description: string, parameters: object, execute: (input: object, ctx?: { signal?: AbortSignal }) => Promise<string> }} tool
93
+ */
94
+ registerTool(tool) {
95
+ this.#tools.set(tool.name, tool);
96
+ }
97
+
98
+ /**
99
+ * Unregister a tool.
100
+ *
101
+ * @param {string} name
102
+ */
103
+ unregisterTool(name) {
104
+ this.#tools.delete(name);
105
+ }
106
+
107
+ /**
108
+ * Get the list of registered tool definitions (for passing to the adapter).
109
+ *
110
+ * @returns {import('./llm/adapter.js').UnifiedToolDef[]}
111
+ */
112
+ #getToolDefs() {
113
+ const defs = [];
114
+ for (const [, tool] of this.#tools) {
115
+ defs.push({
116
+ name: tool.name,
117
+ description: tool.description,
118
+ parameters: tool.parameters,
119
+ });
120
+ }
121
+ return defs;
122
+ }
123
+
124
+ /**
125
+ * Build the system prompt with memory and compact summary.
126
+ *
127
+ * @param {string} mode
128
+ * @param {{ profile?: string, entries?: object[] }} [memory]
129
+ * @param {string} [compactSummary]
130
+ * @returns {string}
131
+ */
132
+ #buildSystemPrompt(mode, memory, compactSummary) {
133
+ return buildSystemPrompt({
134
+ language: this.#config.language || 'en',
135
+ mode,
136
+ toolNames: Array.from(this.#tools.keys()),
137
+ memory,
138
+ compactSummary,
139
+ });
140
+ }
141
+
142
+ /**
143
+ * Perform memory recall for a given prompt.
144
+ *
145
+ * @param {string} prompt
146
+ * @returns {Promise<{ profile: string, entries: object[] }|null>}
147
+ */
148
+ async #recallMemory(prompt) {
149
+ if (!this.#memoryStore) return null;
150
+
151
+ const memory = { profile: '', entries: [] };
152
+
153
+ // Read user profile
154
+ memory.profile = this.#memoryStore.readProfile();
155
+
156
+ // Recall relevant entries
157
+ try {
158
+ const result = await recall({
159
+ prompt,
160
+ adapter: this.#adapter,
161
+ config: this.#config,
162
+ memoryStore: this.#memoryStore,
163
+ });
164
+ memory.entries = result.entries;
165
+ } catch {
166
+ // Recall failure is non-critical
167
+ }
168
+
169
+ return memory;
170
+ }
171
+
172
+ /**
173
+ * Read compact summary from conversation store.
174
+ *
175
+ * @returns {string}
176
+ */
177
+ #getCompactSummary() {
178
+ if (!this.#conversationStore) return '';
179
+ return this.#conversationStore.readCompactSummary();
180
+ }
181
+
182
+ /**
183
+ * Persist user message and assistant response to conversation store.
184
+ *
185
+ * @param {string} userContent
186
+ * @param {string} assistantContent
187
+ * @param {string} mode
188
+ * @param {object[]} [toolCalls]
189
+ */
190
+ #persistMessages(userContent, assistantContent, mode, toolCalls) {
191
+ if (!this.#conversationStore) return;
192
+
193
+ // Persist user message
194
+ this.#conversationStore.append({
195
+ role: 'user',
196
+ content: userContent,
197
+ mode,
198
+ });
199
+
200
+ // Persist assistant message
201
+ const assistantMsg = {
202
+ role: 'assistant',
203
+ content: assistantContent,
204
+ mode,
205
+ model: this.#config.model,
206
+ };
207
+ if (toolCalls && toolCalls.length > 0) {
208
+ assistantMsg.toolCalls = toolCalls;
209
+ }
210
+ this.#conversationStore.append(assistantMsg);
211
+ }
212
+
213
+ /**
214
+ * Check and trigger consolidation if needed.
215
+ *
216
+ * @returns {Promise<{ archivedCount: number, extractedCount: number }|null>}
217
+ */
218
+ async #maybeConsolidate() {
219
+ if (!this.#conversationStore || !this.#memoryStore) return null;
220
+
221
+ const budget = this.#config.messageTokenBudget || 8192;
222
+ if (!shouldConsolidate(this.#conversationStore, budget)) return null;
223
+
224
+ try {
225
+ const result = await consolidate({
226
+ conversationStore: this.#conversationStore,
227
+ memoryStore: this.#memoryStore,
228
+ adapter: this.#adapter,
229
+ config: this.#config,
230
+ budget,
231
+ });
232
+ return { archivedCount: result.archivedCount, extractedCount: result.extractedEntries.length };
233
+ } catch {
234
+ // Consolidation failure is non-critical
235
+ return null;
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Run a query — the main loop.
241
+ *
242
+ * Yields EngineEvent objects that the caller (CLI, web) can consume
243
+ * to render output in real-time.
244
+ *
245
+ * @param {{ prompt: string, mode?: string, messages?: Array, signal?: AbortSignal }} params
246
+ * @yields {EngineEvent}
247
+ */
248
+ async *query({ prompt, mode = 'chat', messages = [], signal }) {
249
+ if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
250
+ yield {
251
+ type: 'error',
252
+ error: new Error('prompt is required and must be a non-empty string'),
253
+ retryable: false,
254
+ };
255
+ return;
256
+ }
257
+
258
+ // ─── Pre-query: Recall + Compact Summary ────────────────
259
+ const memory = await this.#recallMemory(prompt);
260
+ if (memory && memory.entries.length > 0) {
261
+ yield { type: 'recall', entryCount: memory.entries.length, cached: false };
262
+ }
263
+
264
+ const compactSummary = this.#getCompactSummary();
265
+ const systemPrompt = this.#buildSystemPrompt(mode, memory, compactSummary);
266
+
267
+ // Build conversation: existing messages + new user message
268
+ const conversationMessages = [
269
+ ...messages,
270
+ { role: 'user', content: prompt },
271
+ ];
272
+
273
+ const toolDefs = this.#getToolDefs();
274
+ let turnNumber = 0;
275
+ let continueTurns = 0; // auto-continue counter
276
+ let fullResponseText = '';
277
+ let currentModel = this.#config.model;
278
+
279
+ while (true) {
280
+ turnNumber++;
281
+
282
+ // Safety: prevent infinite loops
283
+ if (turnNumber > MAX_TURNS) {
284
+ yield {
285
+ type: 'error',
286
+ error: new Error(`Max turns (${MAX_TURNS}) reached — stopping to prevent infinite loop`),
287
+ retryable: false,
288
+ };
289
+ break;
290
+ }
291
+
292
+ const turnId = this.#trace.startTurn({
293
+ traceId: this.#traceId,
294
+ mode,
295
+ turnNumber,
296
+ });
297
+
298
+ const startTime = Date.now();
299
+ let responseText = '';
300
+ const toolCalls = [];
301
+ let stopReason = 'end_turn';
302
+ const totalUsage = { inputTokens: 0, outputTokens: 0 };
303
+
304
+ yield { type: 'turn_start', turnNumber };
305
+
306
+ try {
307
+ // Stream from adapter
308
+ for await (const event of this.#adapter.stream({
309
+ model: currentModel,
310
+ system: systemPrompt,
311
+ messages: [...conversationMessages],
312
+ tools: toolDefs.length > 0 ? toolDefs : undefined,
313
+ maxTokens: this.#config.maxOutputTokens || 16384,
314
+ signal,
315
+ })) {
316
+ switch (event.type) {
317
+ case 'text_delta':
318
+ responseText += event.text;
319
+ yield event;
320
+ break;
321
+ case 'thinking_delta':
322
+ yield event;
323
+ break;
324
+ case 'tool_call':
325
+ toolCalls.push(event);
326
+ yield event;
327
+ break;
328
+ case 'usage':
329
+ totalUsage.inputTokens += event.inputTokens;
330
+ totalUsage.outputTokens += event.outputTokens;
331
+ yield event;
332
+ break;
333
+ case 'stop':
334
+ stopReason = event.stopReason;
335
+ yield event;
336
+ break;
337
+ case 'error':
338
+ yield event;
339
+ break;
340
+ }
341
+ }
342
+ } catch (err) {
343
+ const latencyMs = Date.now() - startTime;
344
+ this.#trace.endTurn(turnId, {
345
+ model: currentModel,
346
+ inputTokens: totalUsage.inputTokens,
347
+ outputTokens: totalUsage.outputTokens,
348
+ stopReason: 'error',
349
+ latencyMs,
350
+ responseText,
351
+ });
352
+
353
+ // ─── LLMContextError → force compact → retry ──────
354
+ if (err instanceof LLMContextError && this.#conversationStore && this.#memoryStore) {
355
+ const consolidated = await this.#maybeConsolidate();
356
+ if (consolidated && consolidated.archivedCount > 0) {
357
+ yield { type: 'consolidate', archivedCount: consolidated.archivedCount, extractedCount: consolidated.extractedCount };
358
+ yield { type: 'turn_end', turnNumber, stopReason: 'context_overflow_retry' };
359
+ continue; // retry with fewer messages
360
+ }
361
+ }
362
+
363
+ // ─── Fallback model ──────────────────────────────
364
+ const fallbackModel = this.#config.fallbackModel;
365
+ if (fallbackModel && fallbackModel !== currentModel &&
366
+ (err.name === 'LLMRateLimitError' || err.name === 'LLMServerError')) {
367
+ yield { type: 'fallback', from: currentModel, to: fallbackModel, reason: err.message };
368
+ currentModel = fallbackModel;
369
+ yield { type: 'turn_end', turnNumber, stopReason: 'fallback_retry' };
370
+ continue; // retry with fallback model
371
+ }
372
+
373
+ yield {
374
+ type: 'error',
375
+ error: err,
376
+ retryable: err.name === 'LLMRateLimitError' || err.name === 'LLMServerError',
377
+ };
378
+ yield { type: 'turn_end', turnNumber, stopReason: 'error' };
379
+ break;
380
+ }
381
+
382
+ const latencyMs = Date.now() - startTime;
383
+
384
+ // Record turn in debug trace
385
+ this.#trace.endTurn(turnId, {
386
+ model: currentModel,
387
+ inputTokens: totalUsage.inputTokens,
388
+ outputTokens: totalUsage.outputTokens,
389
+ stopReason,
390
+ latencyMs,
391
+ responseText,
392
+ });
393
+
394
+ // Append assistant message to conversation
395
+ const assistantMsg = { role: 'assistant', content: responseText };
396
+ if (toolCalls.length > 0) {
397
+ assistantMsg.toolCalls = toolCalls.map(tc => ({
398
+ id: tc.id,
399
+ name: tc.name,
400
+ input: tc.input,
401
+ }));
402
+ }
403
+ conversationMessages.push(assistantMsg);
404
+ fullResponseText += responseText;
405
+
406
+ // ─── Handle max_tokens → auto-continue ────────────
407
+ if (stopReason === 'max_tokens' && continueTurns < MAX_CONTINUE_TURNS) {
408
+ continueTurns++;
409
+ // Append a "Continue" user message
410
+ conversationMessages.push({ role: 'user', content: 'Continue' });
411
+ yield { type: 'turn_end', turnNumber, stopReason: 'max_tokens_continue' };
412
+ continue; // loop back to call adapter again
413
+ }
414
+
415
+ // If no tool calls, we're done
416
+ if (stopReason !== 'tool_use' || toolCalls.length === 0) {
417
+ yield { type: 'turn_end', turnNumber, stopReason };
418
+
419
+ // ─── Post-query: Persist + Consolidate ────────────
420
+ this.#persistMessages(prompt, fullResponseText, mode, assistantMsg.toolCalls);
421
+
422
+ const consolidated = await this.#maybeConsolidate();
423
+ if (consolidated && consolidated.archivedCount > 0) {
424
+ yield { type: 'consolidate', archivedCount: consolidated.archivedCount, extractedCount: consolidated.extractedCount };
425
+ }
426
+
427
+ break;
428
+ }
429
+
430
+ // Execute tool calls and feed results back
431
+ for (const tc of toolCalls) {
432
+ const tool = this.#tools.get(tc.name);
433
+ const toolStartTime = Date.now();
434
+
435
+ let output;
436
+ let isError = false;
437
+
438
+ if (!tool) {
439
+ output = `Error: unknown tool "${tc.name}"`;
440
+ isError = true;
441
+ yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: true };
442
+ } else {
443
+ try {
444
+ yield { type: 'tool_start', id: tc.id, name: tc.name, input: tc.input };
445
+ output = await tool.execute(tc.input, { signal });
446
+ yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: false };
447
+ } catch (err) {
448
+ output = `Error: ${err.message}`;
449
+ isError = true;
450
+ yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: true };
451
+ }
452
+ }
453
+
454
+ const toolDurationMs = Date.now() - toolStartTime;
455
+
456
+ // Log tool to debug trace
457
+ this.#trace.logTool(turnId, {
458
+ toolName: tc.name,
459
+ toolInput: JSON.stringify(tc.input),
460
+ toolOutput: output,
461
+ durationMs: toolDurationMs,
462
+ isError,
463
+ });
464
+
465
+ // Append tool result to conversation
466
+ conversationMessages.push({
467
+ role: 'tool',
468
+ toolCallId: tc.id,
469
+ content: output,
470
+ isError,
471
+ });
472
+ }
473
+
474
+ yield { type: 'turn_end', turnNumber, stopReason: 'tool_use' };
475
+
476
+ // Loop back to call adapter again with tool results
477
+ }
478
+ }
479
+
480
+ /**
481
+ * Get the trace ID for this engine instance.
482
+ * @returns {string}
483
+ */
484
+ get traceId() {
485
+ return this.#traceId;
486
+ }
487
+
488
+ /**
489
+ * Get registered tool names.
490
+ * @returns {string[]}
491
+ */
492
+ get toolNames() {
493
+ return Array.from(this.#tools.keys());
494
+ }
495
+
496
+ /**
497
+ * Get the conversation store (for external access, e.g., CLI commands).
498
+ * @returns {import('./conversation/persist.js').ConversationStore|null}
499
+ */
500
+ get conversationStore() {
501
+ return this.#conversationStore;
502
+ }
503
+
504
+ /**
505
+ * Get the memory store (for external access, e.g., CLI commands).
506
+ * @returns {import('./memory/store.js').MemoryStore|null}
507
+ */
508
+ get memoryStore() {
509
+ return this.#memoryStore;
510
+ }
511
+ }
package/unify/index.js ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * index.js — Yeaft Unify module entry point
3
+ *
4
+ * Re-exports all public APIs for external consumption.
5
+ */
6
+
7
+ export { initYeaftDir, DEFAULT_YEAFT_DIR } from './init.js';
8
+ export { loadConfig, parseFrontmatter } from './config.js';
9
+ export { DebugTrace, NullTrace, createTrace } from './debug-trace.js';
10
+ export {
11
+ LLMAdapter,
12
+ LLMRateLimitError,
13
+ LLMAuthError,
14
+ LLMContextError,
15
+ LLMServerError,
16
+ LLMAbortError,
17
+ createLLMAdapter,
18
+ } from './llm/adapter.js';
19
+ export { MODEL_REGISTRY, resolveModel, listModels, isKnownModel } from './models.js';
20
+ export { buildSystemPrompt, SUPPORTED_LANGUAGES } from './prompts.js';
21
+ export { Engine } from './engine.js';
22
+ export { ConversationStore, parseMessage, estimateTokens } from './conversation/persist.js';
23
+ export { searchMessages } from './conversation/search.js';
24
+ export { MemoryStore, parseEntry, serializeEntry, MEMORY_KINDS } from './memory/store.js';
25
+ export { recall, extractKeywords, computeFingerprint, clearRecallCache } from './memory/recall.js';
26
+ export { extractMemories } from './memory/extract.js';
27
+ export { consolidate, shouldConsolidate } from './memory/consolidate.js';
package/unify/init.js ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * init.js — Yeaft directory structure initialization
3
+ *
4
+ * Ensures ~/.yeaft/ and all required subdirectories exist.
5
+ * Creates default config.md, MEMORY.md, and conversation/index.md if missing.
6
+ */
7
+
8
+ import { existsSync, mkdirSync, writeFileSync } from 'fs';
9
+ import { join } from 'path';
10
+ import { homedir } from 'os';
11
+
12
+ /** Default directory for Yeaft data. */
13
+ export const DEFAULT_YEAFT_DIR = join(homedir(), '.yeaft');
14
+
15
+ /** Subdirectories that must exist inside the Yeaft data directory. */
16
+ const SUBDIRS = [
17
+ 'conversation/messages',
18
+ 'conversation/cold',
19
+ 'conversation/blobs',
20
+ 'memory/entries',
21
+ 'tasks',
22
+ 'dream',
23
+ 'skills',
24
+ ];
25
+
26
+ /** Default config.md content (YAML frontmatter + markdown body). */
27
+ const DEFAULT_CONFIG = `---
28
+ model: claude-sonnet-4-20250514
29
+ language: en
30
+ debug: false
31
+ maxContextTokens: 200000
32
+ ---
33
+
34
+ # Yeaft Config
35
+
36
+ Edit the YAML frontmatter above to change settings.
37
+ The \`model\` field is a model ID (e.g. \`gpt-5\`, \`claude-sonnet-4-20250514\`).
38
+ Yeaft auto-detects the correct API adapter and endpoint from the model ID.
39
+
40
+ ## Language
41
+
42
+ Set \`language\` in frontmatter or \`YEAFT_LANGUAGE\` env var:
43
+ - \`en\` — English system prompts (default)
44
+ - \`zh\` — Chinese system prompts (中文系统提示)
45
+
46
+ ## Model IDs
47
+
48
+ - \`claude-sonnet-4-20250514\` (default)
49
+ - \`claude-opus-4-20250514\`
50
+ - \`gpt-5\`, \`gpt-5.4\`, \`gpt-4.1\`, \`gpt-4.1-mini\`
51
+ - \`o3\`, \`o4-mini\`
52
+ - \`deepseek-chat\`, \`deepseek-reasoner\`
53
+ - \`gemini-2.5-pro\`, \`gemini-2.5-flash\`
54
+
55
+ ## API Keys
56
+
57
+ Store API keys in \`~/.yeaft/.env\` (recommended) or export as env vars:
58
+
59
+ \`\`\`bash
60
+ # ~/.yeaft/.env
61
+ YEAFT_API_KEY=sk-ant-... # Anthropic
62
+ YEAFT_OPENAI_API_KEY=sk-... # OpenAI / DeepSeek / Gemini
63
+ \`\`\`
64
+
65
+ ## Environment Variables
66
+
67
+ Shell env vars take precedence over .env and config.md:
68
+
69
+ - \`YEAFT_MODEL\` — override model ID
70
+ - \`YEAFT_LANGUAGE\` — language for system prompts (en/zh)
71
+ - \`YEAFT_API_KEY\` — Anthropic API key
72
+ - \`YEAFT_OPENAI_API_KEY\` — OpenAI-compatible API key
73
+ - \`YEAFT_PROXY_URL\` — CopilotProxy URL (default: http://localhost:6628)
74
+ - \`YEAFT_DEBUG\` — enable debug mode (1/true)
75
+ - \`YEAFT_DIR\` — data directory (default: ~/.yeaft)
76
+ `;
77
+
78
+ /** Default MEMORY.md content. */
79
+ const DEFAULT_MEMORY = `# Yeaft Memory
80
+
81
+ This file stores persistent memory entries. The agent will read and update this file.
82
+
83
+ ## Facts
84
+
85
+ ## Preferences
86
+
87
+ ## Project Context
88
+
89
+ `;
90
+
91
+ /** Default conversation/index.md content. */
92
+ const DEFAULT_CONVERSATION_INDEX = `---
93
+ lastMessageId: null
94
+ totalMessages: 0
95
+ ---
96
+
97
+ # Conversation Index
98
+
99
+ This file tracks the conversation state for the "one eternal conversation" model.
100
+ `;
101
+
102
+ /**
103
+ * Initialize the Yeaft data directory structure.
104
+ *
105
+ * @param {string} [dir] — Root directory path. Defaults to ~/.yeaft/
106
+ * @returns {{ dir: string, created: string[] }} — The root dir and list of created paths
107
+ */
108
+ export function initYeaftDir(dir) {
109
+ const root = dir || DEFAULT_YEAFT_DIR;
110
+ const created = [];
111
+
112
+ // Ensure root exists
113
+ if (!existsSync(root)) {
114
+ mkdirSync(root, { recursive: true });
115
+ created.push(root);
116
+ }
117
+
118
+ // Ensure all subdirectories exist
119
+ for (const sub of SUBDIRS) {
120
+ const fullPath = join(root, sub);
121
+ if (!existsSync(fullPath)) {
122
+ mkdirSync(fullPath, { recursive: true });
123
+ created.push(fullPath);
124
+ }
125
+ }
126
+
127
+ // Create default files if they don't exist
128
+ const configPath = join(root, 'config.md');
129
+ if (!existsSync(configPath)) {
130
+ writeFileSync(configPath, DEFAULT_CONFIG, 'utf8');
131
+ created.push(configPath);
132
+ }
133
+
134
+ const memoryPath = join(root, 'memory', 'MEMORY.md');
135
+ if (!existsSync(memoryPath)) {
136
+ writeFileSync(memoryPath, DEFAULT_MEMORY, 'utf8');
137
+ created.push(memoryPath);
138
+ }
139
+
140
+ const indexPath = join(root, 'conversation', 'index.md');
141
+ if (!existsSync(indexPath)) {
142
+ writeFileSync(indexPath, DEFAULT_CONVERSATION_INDEX, 'utf8');
143
+ created.push(indexPath);
144
+ }
145
+
146
+ return { dir: root, created };
147
+ }