@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.
- package/crew/role-query.js +10 -6
- package/package.json +3 -1
- package/sdk/query.js +3 -1
- package/unify/cli.js +735 -0
- package/unify/config.js +269 -0
- package/unify/conversation/persist.js +436 -0
- package/unify/conversation/search.js +65 -0
- package/unify/debug-trace.js +398 -0
- package/unify/engine.js +511 -0
- package/unify/index.js +27 -0
- package/unify/init.js +147 -0
- package/unify/llm/adapter.js +186 -0
- package/unify/llm/anthropic.js +322 -0
- package/unify/llm/chat-completions.js +315 -0
- package/unify/memory/consolidate.js +187 -0
- package/unify/memory/extract.js +97 -0
- package/unify/memory/recall.js +243 -0
- package/unify/memory/store.js +507 -0
- package/unify/models.js +167 -0
- package/unify/prompts.js +109 -0
package/unify/config.js
ADDED
|
@@ -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
|
+
}
|