@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
package/unify/mcp.js
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp.js — MCP (Model Context Protocol) client manager
|
|
3
|
+
*
|
|
4
|
+
* Manages connections to MCP servers and provides a bridge
|
|
5
|
+
* for the mcp_list_tools and mcp_call_tool tools.
|
|
6
|
+
*
|
|
7
|
+
* MCP servers are configured in ~/.yeaft/config.md:
|
|
8
|
+
* ---
|
|
9
|
+
* mcp_servers:
|
|
10
|
+
* - name: github
|
|
11
|
+
* command: npx @mcp/github
|
|
12
|
+
* args: []
|
|
13
|
+
* env:
|
|
14
|
+
* GITHUB_TOKEN: ghp_...
|
|
15
|
+
* - name: slack
|
|
16
|
+
* command: npx @mcp/slack
|
|
17
|
+
* args: []
|
|
18
|
+
* ---
|
|
19
|
+
*
|
|
20
|
+
* Each MCP server communicates via stdio JSON-RPC (MCP protocol).
|
|
21
|
+
*
|
|
22
|
+
* Reference: yeaft-unify-design.md §8, yeaft-unify-core-systems.md §3.2
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { spawn } from 'child_process';
|
|
26
|
+
import { randomUUID } from 'crypto';
|
|
27
|
+
import { EventEmitter } from 'events';
|
|
28
|
+
|
|
29
|
+
// ─── Constants ──────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/** Default tool call timeout (30 seconds). */
|
|
32
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
33
|
+
|
|
34
|
+
/** Server startup timeout (10 seconds). */
|
|
35
|
+
const STARTUP_TIMEOUT_MS = 10000;
|
|
36
|
+
|
|
37
|
+
// ─── MCP Server Connection ─────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A single MCP server connection.
|
|
41
|
+
* Communicates via stdio JSON-RPC.
|
|
42
|
+
*/
|
|
43
|
+
class MCPServerConnection extends EventEmitter {
|
|
44
|
+
#name;
|
|
45
|
+
#process;
|
|
46
|
+
#pendingRequests;
|
|
47
|
+
#tools;
|
|
48
|
+
#ready;
|
|
49
|
+
#buffer;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {string} name — server name (e.g. "github")
|
|
53
|
+
* @param {{ command: string, args?: string[], env?: object }} config
|
|
54
|
+
*/
|
|
55
|
+
constructor(name, config) {
|
|
56
|
+
super();
|
|
57
|
+
this.#name = name;
|
|
58
|
+
this.#process = null;
|
|
59
|
+
this.#pendingRequests = new Map();
|
|
60
|
+
this.#tools = [];
|
|
61
|
+
this.#ready = false;
|
|
62
|
+
this.#buffer = '';
|
|
63
|
+
this.config = config;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get name() { return this.#name; }
|
|
67
|
+
get tools() { return this.#tools; }
|
|
68
|
+
get ready() { return this.#ready; }
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Start the MCP server process and initialize.
|
|
72
|
+
* @returns {Promise<void>}
|
|
73
|
+
*/
|
|
74
|
+
async start() {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const timer = setTimeout(() => {
|
|
77
|
+
reject(new Error(`MCP server "${this.#name}" startup timeout (${STARTUP_TIMEOUT_MS}ms)`));
|
|
78
|
+
}, STARTUP_TIMEOUT_MS);
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
this.#process = spawn(this.config.command, this.config.args || [], {
|
|
82
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
83
|
+
env: { ...process.env, ...this.config.env },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
this.#process.stdout.on('data', (data) => {
|
|
87
|
+
this.#buffer += data.toString();
|
|
88
|
+
this.#processBuffer();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
this.#process.stderr.on('data', (data) => {
|
|
92
|
+
this.emit('error', new Error(`[${this.#name}] stderr: ${data.toString().trim()}`));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
this.#process.on('close', (code) => {
|
|
96
|
+
this.#ready = false;
|
|
97
|
+
this.emit('close', code);
|
|
98
|
+
// Reject all pending requests
|
|
99
|
+
for (const [, { reject: rej }] of this.#pendingRequests) {
|
|
100
|
+
rej(new Error(`MCP server "${this.#name}" closed with code ${code}`));
|
|
101
|
+
}
|
|
102
|
+
this.#pendingRequests.clear();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
this.#process.on('error', (err) => {
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
reject(err);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Initialize MCP protocol
|
|
111
|
+
this.#initialize().then(() => {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
this.#ready = true;
|
|
114
|
+
resolve();
|
|
115
|
+
}).catch((err) => {
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
reject(err);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
} catch (err) {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
reject(err);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Send the MCP initialize request and list tools.
|
|
129
|
+
*/
|
|
130
|
+
async #initialize() {
|
|
131
|
+
// Send initialize
|
|
132
|
+
await this.#rpcCall('initialize', {
|
|
133
|
+
protocolVersion: '2024-11-05',
|
|
134
|
+
capabilities: {},
|
|
135
|
+
clientInfo: { name: 'yeaft', version: '0.1.0' },
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Send initialized notification
|
|
139
|
+
this.#rpcNotify('notifications/initialized', {});
|
|
140
|
+
|
|
141
|
+
// List tools
|
|
142
|
+
const result = await this.#rpcCall('tools/list', {});
|
|
143
|
+
this.#tools = (result?.tools || []).map(tool => ({
|
|
144
|
+
name: `${this.#name}__${tool.name}`,
|
|
145
|
+
serverName: this.#name,
|
|
146
|
+
originalName: tool.name,
|
|
147
|
+
description: tool.description || '',
|
|
148
|
+
inputSchema: tool.inputSchema || { type: 'object', properties: {} },
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Call a tool on this MCP server.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} toolName — original tool name (without server prefix)
|
|
156
|
+
* @param {object} args — tool arguments
|
|
157
|
+
* @param {number} [timeoutMs] — timeout in milliseconds
|
|
158
|
+
* @returns {Promise<object>}
|
|
159
|
+
*/
|
|
160
|
+
async callTool(toolName, args = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
161
|
+
const result = await this.#rpcCall('tools/call', {
|
|
162
|
+
name: toolName,
|
|
163
|
+
arguments: args,
|
|
164
|
+
}, timeoutMs);
|
|
165
|
+
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Send a JSON-RPC request and wait for response.
|
|
171
|
+
*/
|
|
172
|
+
async #rpcCall(method, params, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
173
|
+
const id = randomUUID();
|
|
174
|
+
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
const timer = setTimeout(() => {
|
|
177
|
+
this.#pendingRequests.delete(id);
|
|
178
|
+
reject(new Error(`RPC call "${method}" to "${this.#name}" timed out (${timeoutMs}ms)`));
|
|
179
|
+
}, timeoutMs);
|
|
180
|
+
|
|
181
|
+
this.#pendingRequests.set(id, {
|
|
182
|
+
resolve: (result) => {
|
|
183
|
+
clearTimeout(timer);
|
|
184
|
+
this.#pendingRequests.delete(id);
|
|
185
|
+
resolve(result);
|
|
186
|
+
},
|
|
187
|
+
reject: (err) => {
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
this.#pendingRequests.delete(id);
|
|
190
|
+
reject(err);
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const message = JSON.stringify({ jsonrpc: '2.0', id, method, params });
|
|
195
|
+
this.#process.stdin.write(message + '\n');
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Send a JSON-RPC notification (no response expected).
|
|
201
|
+
*/
|
|
202
|
+
#rpcNotify(method, params) {
|
|
203
|
+
const message = JSON.stringify({ jsonrpc: '2.0', method, params });
|
|
204
|
+
this.#process.stdin.write(message + '\n');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Process the stdout buffer for complete JSON-RPC messages.
|
|
209
|
+
*/
|
|
210
|
+
#processBuffer() {
|
|
211
|
+
const lines = this.#buffer.split('\n');
|
|
212
|
+
this.#buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
|
213
|
+
|
|
214
|
+
for (const line of lines) {
|
|
215
|
+
const trimmed = line.trim();
|
|
216
|
+
if (!trimmed) continue;
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const msg = JSON.parse(trimmed);
|
|
220
|
+
|
|
221
|
+
if (msg.id && this.#pendingRequests.has(msg.id)) {
|
|
222
|
+
const pending = this.#pendingRequests.get(msg.id);
|
|
223
|
+
if (msg.error) {
|
|
224
|
+
pending.reject(new Error(`MCP error: ${msg.error.message || JSON.stringify(msg.error)}`));
|
|
225
|
+
} else {
|
|
226
|
+
pending.resolve(msg.result);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Handle notifications from server
|
|
231
|
+
if (!msg.id && msg.method) {
|
|
232
|
+
this.emit('notification', msg);
|
|
233
|
+
}
|
|
234
|
+
} catch {
|
|
235
|
+
// Not valid JSON — ignore (could be debug output)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Stop the MCP server process.
|
|
242
|
+
*/
|
|
243
|
+
async stop() {
|
|
244
|
+
if (this.#process) {
|
|
245
|
+
this.#ready = false;
|
|
246
|
+
this.#process.kill('SIGTERM');
|
|
247
|
+
// Wait a bit then force kill
|
|
248
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
249
|
+
if (this.#process && !this.#process.killed) {
|
|
250
|
+
this.#process.kill('SIGKILL');
|
|
251
|
+
}
|
|
252
|
+
this.#process = null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ─── MCP Manager ────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* MCPManager — manages multiple MCP server connections.
|
|
261
|
+
*/
|
|
262
|
+
export class MCPManager {
|
|
263
|
+
/** @type {Map<string, MCPServerConnection>} */
|
|
264
|
+
#servers = new Map();
|
|
265
|
+
|
|
266
|
+
/** @type {Map<string, object>} */
|
|
267
|
+
#toolIndex = new Map(); // fullToolName → { server, tool }
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Connect to an MCP server.
|
|
271
|
+
*
|
|
272
|
+
* @param {{ name: string, command: string, args?: string[], env?: object }} serverConfig
|
|
273
|
+
* @returns {Promise<{ name: string, toolCount: number }>}
|
|
274
|
+
*/
|
|
275
|
+
async connect(serverConfig) {
|
|
276
|
+
const { name } = serverConfig;
|
|
277
|
+
|
|
278
|
+
// Disconnect existing if any
|
|
279
|
+
if (this.#servers.has(name)) {
|
|
280
|
+
await this.disconnect(name);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const connection = new MCPServerConnection(name, serverConfig);
|
|
284
|
+
await connection.start();
|
|
285
|
+
|
|
286
|
+
this.#servers.set(name, connection);
|
|
287
|
+
|
|
288
|
+
// Index tools
|
|
289
|
+
for (const tool of connection.tools) {
|
|
290
|
+
this.#toolIndex.set(tool.name, { server: connection, tool });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return { name, toolCount: connection.tools.length };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Connect to all servers from config.
|
|
298
|
+
*
|
|
299
|
+
* @param {object[]} serverConfigs — array of server configurations
|
|
300
|
+
* @returns {Promise<{ connected: string[], failed: { name: string, error: string }[] }>}
|
|
301
|
+
*/
|
|
302
|
+
async connectAll(serverConfigs) {
|
|
303
|
+
const connected = [];
|
|
304
|
+
const failed = [];
|
|
305
|
+
|
|
306
|
+
for (const config of serverConfigs) {
|
|
307
|
+
try {
|
|
308
|
+
await this.connect(config);
|
|
309
|
+
connected.push(config.name);
|
|
310
|
+
} catch (err) {
|
|
311
|
+
failed.push({ name: config.name, error: err.message });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return { connected, failed };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Disconnect from a specific MCP server.
|
|
320
|
+
*
|
|
321
|
+
* @param {string} name — server name
|
|
322
|
+
*/
|
|
323
|
+
async disconnect(name) {
|
|
324
|
+
const connection = this.#servers.get(name);
|
|
325
|
+
if (connection) {
|
|
326
|
+
// Remove tools from index
|
|
327
|
+
for (const tool of connection.tools) {
|
|
328
|
+
this.#toolIndex.delete(tool.name);
|
|
329
|
+
}
|
|
330
|
+
await connection.stop();
|
|
331
|
+
this.#servers.delete(name);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Disconnect from all servers.
|
|
337
|
+
*/
|
|
338
|
+
async disconnectAll() {
|
|
339
|
+
const names = [...this.#servers.keys()];
|
|
340
|
+
for (const name of names) {
|
|
341
|
+
await this.disconnect(name);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* List all available tools from all connected servers.
|
|
347
|
+
*
|
|
348
|
+
* @param {string} [serverFilter] — optionally filter by server name
|
|
349
|
+
* @returns {object[]}
|
|
350
|
+
*/
|
|
351
|
+
listTools(serverFilter) {
|
|
352
|
+
const tools = [];
|
|
353
|
+
|
|
354
|
+
for (const [, { server, tool }] of this.#toolIndex) {
|
|
355
|
+
if (serverFilter && server.name !== serverFilter) continue;
|
|
356
|
+
tools.push({
|
|
357
|
+
name: tool.name,
|
|
358
|
+
server: server.name,
|
|
359
|
+
description: tool.description,
|
|
360
|
+
inputSchema: tool.inputSchema,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return tools;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Call a tool by its full name (server__toolName).
|
|
369
|
+
*
|
|
370
|
+
* @param {string} fullToolName — e.g. "github__list_prs"
|
|
371
|
+
* @param {object} [args={}] — tool arguments
|
|
372
|
+
* @param {number} [timeoutMs] — timeout
|
|
373
|
+
* @returns {Promise<object>}
|
|
374
|
+
*/
|
|
375
|
+
async callTool(fullToolName, args = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
376
|
+
const entry = this.#toolIndex.get(fullToolName);
|
|
377
|
+
if (!entry) {
|
|
378
|
+
throw new Error(`MCP tool "${fullToolName}" not found. Available: ${[...this.#toolIndex.keys()].join(', ') || '(none)'}`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const { server, tool } = entry;
|
|
382
|
+
if (!server.ready) {
|
|
383
|
+
throw new Error(`MCP server "${server.name}" is not ready`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return server.callTool(tool.originalName, args, timeoutMs);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Get the status of all servers.
|
|
391
|
+
*
|
|
392
|
+
* @returns {{ name: string, ready: boolean, toolCount: number }[]}
|
|
393
|
+
*/
|
|
394
|
+
status() {
|
|
395
|
+
return [...this.#servers.entries()].map(([name, conn]) => ({
|
|
396
|
+
name,
|
|
397
|
+
ready: conn.ready,
|
|
398
|
+
toolCount: conn.tools.length,
|
|
399
|
+
}));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Check if any servers are connected.
|
|
404
|
+
* @returns {boolean}
|
|
405
|
+
*/
|
|
406
|
+
get hasServers() {
|
|
407
|
+
return this.#servers.size > 0;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Total tool count across all servers.
|
|
412
|
+
* @returns {number}
|
|
413
|
+
*/
|
|
414
|
+
get toolCount() {
|
|
415
|
+
return this.#toolIndex.size;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Create an MCPManager and optionally connect to servers from config.
|
|
421
|
+
*
|
|
422
|
+
* @param {object} [config] — { mcp_servers: [...] }
|
|
423
|
+
* @returns {Promise<MCPManager>}
|
|
424
|
+
*/
|
|
425
|
+
export async function createMCPManager(config) {
|
|
426
|
+
const manager = new MCPManager();
|
|
427
|
+
|
|
428
|
+
if (config?.mcp_servers && Array.isArray(config.mcp_servers)) {
|
|
429
|
+
await manager.connectAll(config.mcp_servers);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return manager;
|
|
433
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* consolidate.js — Consolidate = compact + extract (one LLM call)
|
|
3
|
+
*
|
|
4
|
+
* Triggered when hot_tokens > MESSAGE_TOKEN_BUDGET.
|
|
5
|
+
* One LLM call does two things simultaneously:
|
|
6
|
+
* 1. Generate compact summary → append to compact.md ("short-term memory")
|
|
7
|
+
* 2. Extract memory entries → write to entries/ ("long-term memory")
|
|
8
|
+
*
|
|
9
|
+
* After consolidation:
|
|
10
|
+
* - Processed messages moved from messages/ to cold/
|
|
11
|
+
* - index.md + scopes.md updated
|
|
12
|
+
*
|
|
13
|
+
* Reference: yeaft-unify-core-systems.md §3.1, §4.2
|
|
14
|
+
* yeaft-unify-design.md §6.1
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { extractMemories } from './extract.js';
|
|
18
|
+
|
|
19
|
+
// ─── Constants ──────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/** Default MESSAGE_TOKEN_BUDGET (context * 4%, default ~8192). */
|
|
22
|
+
export const DEFAULT_MESSAGE_TOKEN_BUDGET = 8192;
|
|
23
|
+
|
|
24
|
+
/** After compact, keep this fraction of the budget. */
|
|
25
|
+
export const COMPACT_KEEP_RATIO = 0.4;
|
|
26
|
+
|
|
27
|
+
/** Minimum messages to keep hot (newest). */
|
|
28
|
+
const MIN_KEEP_MESSAGES = 3;
|
|
29
|
+
|
|
30
|
+
// ─── Consolidate ────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if consolidation should be triggered.
|
|
34
|
+
*
|
|
35
|
+
* @param {import('../conversation/persist.js').ConversationStore} conversationStore
|
|
36
|
+
* @param {number} [budget] — MESSAGE_TOKEN_BUDGET
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
export function shouldConsolidate(conversationStore, budget = DEFAULT_MESSAGE_TOKEN_BUDGET) {
|
|
40
|
+
const hotTokens = conversationStore.hotTokens();
|
|
41
|
+
return hotTokens > budget;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Determine which messages to archive (move to cold).
|
|
46
|
+
* Strategy: from oldest, accumulate tokens until remaining ≤ budget * 40%.
|
|
47
|
+
* Always keep at least MIN_KEEP_MESSAGES.
|
|
48
|
+
*
|
|
49
|
+
* @param {object[]} messages — all hot messages, sorted chronologically
|
|
50
|
+
* @param {number} budget — MESSAGE_TOKEN_BUDGET
|
|
51
|
+
* @returns {{ toArchive: object[], toKeep: object[] }}
|
|
52
|
+
*/
|
|
53
|
+
export function partitionMessages(messages, budget = DEFAULT_MESSAGE_TOKEN_BUDGET) {
|
|
54
|
+
if (messages.length <= MIN_KEEP_MESSAGES) {
|
|
55
|
+
return { toArchive: [], toKeep: messages };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const keepBudget = Math.floor(budget * COMPACT_KEEP_RATIO);
|
|
59
|
+
|
|
60
|
+
// Work backwards from newest: accumulate tokens until we hit keepBudget
|
|
61
|
+
let keepTokens = 0;
|
|
62
|
+
let keepStart = messages.length;
|
|
63
|
+
|
|
64
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
65
|
+
const msgTokens = messages[i].tokens_est || 0;
|
|
66
|
+
if (keepTokens + msgTokens > keepBudget && (messages.length - i) >= MIN_KEEP_MESSAGES) {
|
|
67
|
+
keepStart = i + 1;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
keepTokens += msgTokens;
|
|
71
|
+
if (i === 0) keepStart = 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Ensure at least MIN_KEEP_MESSAGES are kept
|
|
75
|
+
keepStart = Math.min(keepStart, messages.length - MIN_KEEP_MESSAGES);
|
|
76
|
+
keepStart = Math.max(keepStart, 0);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
toArchive: messages.slice(0, keepStart),
|
|
80
|
+
toKeep: messages.slice(keepStart),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Generate a compact summary of messages.
|
|
86
|
+
*
|
|
87
|
+
* @param {object[]} messages — messages to summarize
|
|
88
|
+
* @param {object} adapter — LLM adapter with .call()
|
|
89
|
+
* @param {object} config — { model }
|
|
90
|
+
* @returns {Promise<string>} — compact summary text
|
|
91
|
+
*/
|
|
92
|
+
async function generateSummary(messages, adapter, config) {
|
|
93
|
+
const conversation = messages.map(m => {
|
|
94
|
+
const prefix = m.role === 'user' ? 'User' : m.role === 'assistant' ? 'Assistant' : m.role;
|
|
95
|
+
return `[${prefix}]: ${(m.content || '').slice(0, 500)}`;
|
|
96
|
+
}).join('\n\n');
|
|
97
|
+
|
|
98
|
+
const system = 'You are a conversation summarizer. Summarize the conversation concisely in 2-3 paragraphs, preserving key decisions, facts, and context. Write in the same language as the conversation.';
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const result = await adapter.call({
|
|
102
|
+
model: config.model,
|
|
103
|
+
system,
|
|
104
|
+
messages: [{ role: 'user', content: `Summarize this conversation:\n\n${conversation}` }],
|
|
105
|
+
maxTokens: 1024,
|
|
106
|
+
});
|
|
107
|
+
return result.text.trim();
|
|
108
|
+
} catch {
|
|
109
|
+
// Fallback: simple concatenation of first/last messages
|
|
110
|
+
const first = messages[0]?.content?.slice(0, 200) || '';
|
|
111
|
+
const last = messages[messages.length - 1]?.content?.slice(0, 200) || '';
|
|
112
|
+
return `[Auto-summary failed] Started with: ${first}... Ended with: ${last}`;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Run the full Consolidate pipeline.
|
|
118
|
+
*
|
|
119
|
+
* 1. Partition messages (what to archive vs keep)
|
|
120
|
+
* 2. Generate compact summary (LLM call)
|
|
121
|
+
* 3. Extract memory entries (LLM call)
|
|
122
|
+
* 4. Move archived messages to cold/
|
|
123
|
+
* 5. Update compact.md, index.md, scopes.md
|
|
124
|
+
*
|
|
125
|
+
* @param {{
|
|
126
|
+
* conversationStore: import('../conversation/persist.js').ConversationStore,
|
|
127
|
+
* memoryStore: import('./store.js').MemoryStore,
|
|
128
|
+
* adapter: object,
|
|
129
|
+
* config: object,
|
|
130
|
+
* budget?: number
|
|
131
|
+
* }} params
|
|
132
|
+
* @returns {Promise<{ compactSummary: string, extractedEntries: string[], archivedCount: number }>}
|
|
133
|
+
*/
|
|
134
|
+
export async function consolidate({ conversationStore, memoryStore, adapter, config, budget = DEFAULT_MESSAGE_TOKEN_BUDGET }) {
|
|
135
|
+
// Load all hot messages
|
|
136
|
+
const messages = conversationStore.loadAll();
|
|
137
|
+
|
|
138
|
+
if (messages.length <= MIN_KEEP_MESSAGES) {
|
|
139
|
+
return { compactSummary: '', extractedEntries: [], archivedCount: 0 };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Step 1: Partition
|
|
143
|
+
const { toArchive, toKeep } = partitionMessages(messages, budget);
|
|
144
|
+
|
|
145
|
+
if (toArchive.length === 0) {
|
|
146
|
+
return { compactSummary: '', extractedEntries: [], archivedCount: 0 };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Step 2: Generate compact summary
|
|
150
|
+
const compactSummary = await generateSummary(toArchive, adapter, config);
|
|
151
|
+
|
|
152
|
+
// Step 3: Extract memory entries
|
|
153
|
+
const extracted = await extractMemories({ messages: toArchive, adapter, config });
|
|
154
|
+
|
|
155
|
+
// Step 4: Move archived messages to cold
|
|
156
|
+
const archiveIds = toArchive.map(m => m.id).filter(Boolean);
|
|
157
|
+
conversationStore.moveToColdBatch(archiveIds);
|
|
158
|
+
|
|
159
|
+
// Step 5a: Update compact.md
|
|
160
|
+
if (compactSummary) {
|
|
161
|
+
conversationStore.updateCompactSummary(compactSummary);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Step 5b: Write extracted memory entries
|
|
165
|
+
const entryNames = [];
|
|
166
|
+
for (const entry of extracted) {
|
|
167
|
+
const slug = memoryStore.writeEntry(entry);
|
|
168
|
+
entryNames.push(slug);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Step 5c: Update index.md
|
|
172
|
+
const lastMsg = toKeep[toKeep.length - 1];
|
|
173
|
+
conversationStore.updateIndex({
|
|
174
|
+
lastMessageId: lastMsg?.id || null,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Step 5d: Rebuild scopes.md
|
|
178
|
+
if (entryNames.length > 0) {
|
|
179
|
+
memoryStore.rebuildScopes();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
compactSummary,
|
|
184
|
+
extractedEntries: entryNames,
|
|
185
|
+
archivedCount: archiveIds.length,
|
|
186
|
+
};
|
|
187
|
+
}
|