@ghenya/clinn 0.8.8 → 0.9.0
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/Logos/StartLogo.txt +1 -1
- package/Mem/history.js +326 -24
- package/Mem/index.js +123 -14
- package/Src/agent.cjs +74 -11
- package/Src/index.js +81 -8
- package/Src/index.jsx +366 -108
- package/Tools/extended_tools.js +83 -8
- package/Tools/index.js +1 -1
- package/Tools/search_tools.js +55 -1
- package/config.json +1 -1
- package/package.json +8 -8
package/Logos/StartLogo.txt
CHANGED
package/Mem/history.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
2
|
const path = require("path");
|
|
3
3
|
const os = require("os");
|
|
4
|
+
const crypto = require("crypto");
|
|
4
5
|
|
|
5
6
|
const MEM_DIR = path.join(os.homedir(), ".clinn", "mem");
|
|
7
|
+
const SESSIONS_FILE = path.join(MEM_DIR, "sessions.json");
|
|
8
|
+
|
|
9
|
+
// ─── helpers ──────────────────────────────────────────────
|
|
6
10
|
|
|
7
11
|
function ensureDir() {
|
|
8
12
|
if (!fs.existsSync(MEM_DIR)) fs.mkdirSync(MEM_DIR, { recursive: true });
|
|
@@ -31,7 +35,77 @@ function saveFile(filePath, data) {
|
|
|
31
35
|
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
function
|
|
38
|
+
function loadSessions() {
|
|
39
|
+
ensureDir();
|
|
40
|
+
const data = loadFile(SESSIONS_FILE);
|
|
41
|
+
return data.sessions || {};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function saveSessions(sessions) {
|
|
45
|
+
saveFile(SESSIONS_FILE, { sessions });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** generate a short session id: timestamp + random hex */
|
|
49
|
+
function genSessionId() {
|
|
50
|
+
return Date.now().toString(36) + "-" + crypto.randomBytes(3).toString("hex");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── session management ───────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/** Start a new session. Called on clinn startup and /new. */
|
|
56
|
+
function startSession() {
|
|
57
|
+
const sessions = loadSessions();
|
|
58
|
+
|
|
59
|
+
// mark any previously active session as ended
|
|
60
|
+
for (const [id, s] of Object.entries(sessions)) {
|
|
61
|
+
if (s.status === "active") {
|
|
62
|
+
s.status = "ended";
|
|
63
|
+
s.ended = new Date().toISOString();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const id = genSessionId();
|
|
68
|
+
sessions[id] = {
|
|
69
|
+
id,
|
|
70
|
+
started: new Date().toISOString(),
|
|
71
|
+
ended: null,
|
|
72
|
+
turnCount: 0,
|
|
73
|
+
title: null,
|
|
74
|
+
firstMessage: null,
|
|
75
|
+
status: "active",
|
|
76
|
+
};
|
|
77
|
+
saveSessions(sessions);
|
|
78
|
+
return id;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** End the current session. */
|
|
82
|
+
function endSession(sessionId) {
|
|
83
|
+
if (!sessionId) return;
|
|
84
|
+
const sessions = loadSessions();
|
|
85
|
+
const s = sessions[sessionId];
|
|
86
|
+
if (s && s.status === "active") {
|
|
87
|
+
s.status = "ended";
|
|
88
|
+
s.ended = new Date().toISOString();
|
|
89
|
+
saveSessions(sessions);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Update session metadata after each turn. */
|
|
94
|
+
function _updateSessionMeta(sessionId, userMessage) {
|
|
95
|
+
const sessions = loadSessions();
|
|
96
|
+
const s = sessions[sessionId];
|
|
97
|
+
if (!s) return;
|
|
98
|
+
s.turnCount = (s.turnCount || 0) + 1;
|
|
99
|
+
if (!s.title && userMessage) {
|
|
100
|
+
s.title = userMessage.slice(0, 80);
|
|
101
|
+
s.firstMessage = userMessage;
|
|
102
|
+
}
|
|
103
|
+
saveSessions(sessions);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── turn saving (backward-compat: sessionId optional) ─────
|
|
107
|
+
|
|
108
|
+
function saveTurn(userMessage, assistantResponse, cwd, allMessages, sessionId) {
|
|
35
109
|
ensureDir();
|
|
36
110
|
const file = todayFile();
|
|
37
111
|
const records = loadFile(file);
|
|
@@ -42,10 +116,15 @@ function saveTurn(userMessage, assistantResponse, cwd, allMessages) {
|
|
|
42
116
|
user: userMessage,
|
|
43
117
|
assistant: assistantResponse,
|
|
44
118
|
messages: allMessages || [],
|
|
119
|
+
sessionId: sessionId || null,
|
|
45
120
|
});
|
|
46
121
|
saveFile(file, records);
|
|
122
|
+
|
|
123
|
+
if (sessionId) _updateSessionMeta(sessionId, userMessage);
|
|
47
124
|
}
|
|
48
125
|
|
|
126
|
+
// ─── listing / browsing ────────────────────────────────────
|
|
127
|
+
|
|
49
128
|
function listHistoryFiles() {
|
|
50
129
|
ensureDir();
|
|
51
130
|
try {
|
|
@@ -58,6 +137,21 @@ function listHistoryFiles() {
|
|
|
58
137
|
}
|
|
59
138
|
}
|
|
60
139
|
|
|
140
|
+
function getFileList() {
|
|
141
|
+
const files = listHistoryFiles();
|
|
142
|
+
return files.map((f) => {
|
|
143
|
+
const filePath = path.join(MEM_DIR, f);
|
|
144
|
+
const records = loadFile(filePath);
|
|
145
|
+
const stat = fs.statSync(filePath);
|
|
146
|
+
return {
|
|
147
|
+
file: f,
|
|
148
|
+
turns: records.length,
|
|
149
|
+
size: Math.round(stat.size / 1024),
|
|
150
|
+
created: stat.birthtime.toISOString(),
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
61
155
|
function listRecentTurns(limit = 20) {
|
|
62
156
|
const files = listHistoryFiles();
|
|
63
157
|
const turns = [];
|
|
@@ -71,60 +165,268 @@ function listRecentTurns(limit = 20) {
|
|
|
71
165
|
return turns;
|
|
72
166
|
}
|
|
73
167
|
|
|
168
|
+
function loadFileTurns(fileName, limit = 50) {
|
|
169
|
+
const filePath = path.join(MEM_DIR, fileName);
|
|
170
|
+
const records = loadFile(filePath);
|
|
171
|
+
return records.slice(-limit);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ─── session listing ──────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/** List sessions ordered by start time (newest first). */
|
|
177
|
+
function listSessions(limit = 20) {
|
|
178
|
+
const sessions = loadSessions();
|
|
179
|
+
return Object.values(sessions)
|
|
180
|
+
.sort((a, b) => new Date(b.started) - new Date(a.started))
|
|
181
|
+
.slice(0, limit);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Get a single session by id. */
|
|
185
|
+
function getSession(sessionId) {
|
|
186
|
+
const sessions = loadSessions();
|
|
187
|
+
return sessions[sessionId] || null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Load all turns belonging to a session (from all history files). */
|
|
191
|
+
function loadSessionTurns(sessionId, maxTurns = 200) {
|
|
192
|
+
const files = listHistoryFiles();
|
|
193
|
+
const turns = [];
|
|
194
|
+
for (const file of files) {
|
|
195
|
+
const records = loadFile(path.join(MEM_DIR, file));
|
|
196
|
+
for (const r of records) {
|
|
197
|
+
if (r.sessionId === sessionId) {
|
|
198
|
+
turns.push({ ...r, file });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
turns.sort((a, b) => a.id - b.id);
|
|
203
|
+
return turns.length > maxTurns ? turns.slice(-maxTurns) : turns;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ─── enhanced search ──────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Score a text against a query. Tokens get partial match scores.
|
|
210
|
+
* Returns 0 if no match.
|
|
211
|
+
*/
|
|
212
|
+
function _scoreText(text, queryLower, queryTokens) {
|
|
213
|
+
if (!text) return 0;
|
|
214
|
+
const lower = text.toLowerCase();
|
|
215
|
+
let score = 0;
|
|
216
|
+
// exact phrase match
|
|
217
|
+
if (lower.includes(queryLower)) score += 50;
|
|
218
|
+
// token match
|
|
219
|
+
for (const tok of queryTokens) {
|
|
220
|
+
if (tok && lower.includes(tok)) score += 15;
|
|
221
|
+
}
|
|
222
|
+
// full exact match (highest)
|
|
223
|
+
if (lower === queryLower) score = 100;
|
|
224
|
+
return score;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Search turns across history files with relevance scoring.
|
|
229
|
+
* Returns matches with surrounding context.
|
|
230
|
+
*/
|
|
74
231
|
function searchHistory(query, limit = 10) {
|
|
75
232
|
const files = listHistoryFiles();
|
|
76
233
|
const results = [];
|
|
77
234
|
const q = query.toLowerCase();
|
|
235
|
+
const tokens = q.split(/\s+/).filter(Boolean);
|
|
236
|
+
|
|
78
237
|
for (const file of files) {
|
|
79
|
-
if (results.length >= limit) break;
|
|
238
|
+
if (results.length >= limit * 3) break; // buffer for scoring
|
|
80
239
|
const records = loadFile(path.join(MEM_DIR, file));
|
|
81
240
|
for (let i = records.length - 1; i >= 0; i--) {
|
|
82
241
|
const r = records[i];
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
|
|
242
|
+
const userScore = _scoreText(r.user || "", q, tokens);
|
|
243
|
+
const asstScore = _scoreText(r.assistant || "", q, tokens);
|
|
244
|
+
const msgScore = _scoreText(JSON.stringify(r.messages || []), q, tokens);
|
|
245
|
+
const totalScore = Math.max(userScore, asstScore, msgScore);
|
|
246
|
+
|
|
247
|
+
if (totalScore > 0) {
|
|
87
248
|
results.push({
|
|
88
249
|
file,
|
|
89
250
|
cwd: r.cwd || "",
|
|
90
|
-
user: r.user.slice(0, 200),
|
|
91
|
-
assistant: r.assistant.slice(0, 300),
|
|
251
|
+
user: r.user ? r.user.slice(0, 200) : "",
|
|
252
|
+
assistant: r.assistant ? r.assistant.slice(0, 300) : "",
|
|
92
253
|
time: r.time,
|
|
254
|
+
sessionId: r.sessionId || null,
|
|
255
|
+
score: totalScore,
|
|
93
256
|
});
|
|
94
|
-
if (results.length >= limit) break;
|
|
95
257
|
}
|
|
96
258
|
}
|
|
97
259
|
}
|
|
98
|
-
|
|
260
|
+
|
|
261
|
+
// sort by score desc then time desc
|
|
262
|
+
results.sort((a, b) => b.score - a.score || b.time.localeCompare(a.time));
|
|
263
|
+
return results.slice(0, limit);
|
|
99
264
|
}
|
|
100
265
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
266
|
+
/**
|
|
267
|
+
* Search sessions by keyword (title + first message).
|
|
268
|
+
* Returns ranked sessions.
|
|
269
|
+
*/
|
|
270
|
+
function searchSessions(query, limit = 10) {
|
|
271
|
+
const sessions = listSessions(500);
|
|
272
|
+
const q = query.toLowerCase();
|
|
273
|
+
const tokens = q.split(/\s+/).filter(Boolean);
|
|
274
|
+
|
|
275
|
+
const scored = sessions.map((s) => {
|
|
276
|
+
const titleScore = _scoreText(s.title || "", q, tokens);
|
|
277
|
+
const firstMsgScore = _scoreText(s.firstMessage || "", q, tokens);
|
|
278
|
+
const totalScore = Math.max(titleScore, firstMsgScore);
|
|
279
|
+
return { session: s, score: totalScore };
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
return scored
|
|
283
|
+
.filter((s) => s.score > 0)
|
|
284
|
+
.sort((a, b) => b.score - a.score)
|
|
285
|
+
.slice(0, limit)
|
|
286
|
+
.map((s) => s.session);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Full-text search with context: return matched turns + session info.
|
|
291
|
+
* Best for "what did we talk about X" queries.
|
|
292
|
+
*/
|
|
293
|
+
function searchHistoryWithContext(query, limit = 10) {
|
|
294
|
+
const matches = searchHistory(query, limit);
|
|
295
|
+
const sessions = loadSessions();
|
|
296
|
+
|
|
297
|
+
return matches.map((m) => {
|
|
298
|
+
const session = m.sessionId ? sessions[m.sessionId] : null;
|
|
107
299
|
return {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
created: stat.birthtime.toISOString(),
|
|
300
|
+
...m,
|
|
301
|
+
sessionTitle: session ? session.title : "(旧记录, 无 session)",
|
|
302
|
+
sessionStarted: session ? session.started : null,
|
|
112
303
|
};
|
|
113
304
|
});
|
|
114
305
|
}
|
|
115
306
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
307
|
+
/**
|
|
308
|
+
* Global full-text search that scans raw history JSON files
|
|
309
|
+
* and returns results organized by session.
|
|
310
|
+
*/
|
|
311
|
+
function globalSearch(query, limit = 10) {
|
|
312
|
+
const files = listHistoryFiles();
|
|
313
|
+
const q = query.toLowerCase();
|
|
314
|
+
const tokens = q.split(/\s+/).filter(Boolean);
|
|
315
|
+
const sessions = loadSessions();
|
|
316
|
+
const bySession = {};
|
|
317
|
+
|
|
318
|
+
for (const file of files) {
|
|
319
|
+
const records = loadFile(path.join(MEM_DIR, file));
|
|
320
|
+
for (let i = 0; i < records.length; i++) {
|
|
321
|
+
const r = records[i];
|
|
322
|
+
const raw = JSON.stringify(r).toLowerCase();
|
|
323
|
+
const score = _scoreText(raw, q, tokens);
|
|
324
|
+
|
|
325
|
+
if (score > 0) {
|
|
326
|
+
const sid = r.sessionId || "legacy";
|
|
327
|
+
if (!bySession[sid]) {
|
|
328
|
+
const s = sessions[sid];
|
|
329
|
+
bySession[sid] = {
|
|
330
|
+
sessionId: sid,
|
|
331
|
+
sessionTitle: s ? s.title : (sid === "legacy" ? "(旧记录)" : "(未知)"),
|
|
332
|
+
sessionStarted: s ? s.started : null,
|
|
333
|
+
totalScore: 0,
|
|
334
|
+
matchCount: 0,
|
|
335
|
+
topMatches: [],
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
const grp = bySession[sid];
|
|
339
|
+
grp.totalScore += score;
|
|
340
|
+
grp.matchCount++;
|
|
341
|
+
if (grp.topMatches.length < 3) {
|
|
342
|
+
grp.topMatches.push({
|
|
343
|
+
time: r.time,
|
|
344
|
+
user: r.user ? r.user.slice(0, 120) : "",
|
|
345
|
+
file,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return Object.values(bySession)
|
|
353
|
+
.sort((a, b) => b.totalScore - a.totalScore)
|
|
354
|
+
.slice(0, limit);
|
|
120
355
|
}
|
|
121
356
|
|
|
357
|
+
// ─── migration: add sessionId to old records ───────────────
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Migrate legacy turns (without sessionId) — assigns a synthetic
|
|
361
|
+
* session per file to keep searches working.
|
|
362
|
+
*/
|
|
363
|
+
function migrateLegacySessions() {
|
|
364
|
+
const sessions = loadSessions();
|
|
365
|
+
const files = listHistoryFiles();
|
|
366
|
+
let migrated = 0;
|
|
367
|
+
|
|
368
|
+
for (const file of files) {
|
|
369
|
+
const filePath = path.join(MEM_DIR, file);
|
|
370
|
+
const records = loadFile(filePath);
|
|
371
|
+
let needsSave = false;
|
|
372
|
+
|
|
373
|
+
// gather turns that need a sessionId
|
|
374
|
+
const legacyNeeding = records.filter((r) => !r.sessionId);
|
|
375
|
+
if (legacyNeeding.length === 0) continue;
|
|
376
|
+
|
|
377
|
+
// assign a per-file synthetic session
|
|
378
|
+
const fakeId = "migrated-" + file.replace("history-", "").replace(".json", "");
|
|
379
|
+
if (!sessions[fakeId]) {
|
|
380
|
+
sessions[fakeId] = {
|
|
381
|
+
id: fakeId,
|
|
382
|
+
started: legacyNeeding[0].time,
|
|
383
|
+
ended: legacyNeeding[legacyNeeding.length - 1].time,
|
|
384
|
+
turnCount: legacyNeeding.length,
|
|
385
|
+
title: legacyNeeding[0].user ? legacyNeeding[0].user.slice(0, 80) : "(旧记录)",
|
|
386
|
+
firstMessage: legacyNeeding[0].user || "",
|
|
387
|
+
status: "ended",
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
for (const r of records) {
|
|
392
|
+
if (!r.sessionId) {
|
|
393
|
+
r.sessionId = fakeId;
|
|
394
|
+
needsSave = true;
|
|
395
|
+
migrated++;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (needsSave) saveFile(filePath, records);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (migrated > 0) saveSessions(sessions);
|
|
403
|
+
return migrated;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ─── exports ───────────────────────────────────────────────
|
|
407
|
+
|
|
122
408
|
module.exports = {
|
|
409
|
+
// turn-level
|
|
123
410
|
saveTurn,
|
|
124
411
|
listRecentTurns,
|
|
125
412
|
searchHistory,
|
|
413
|
+
searchHistoryWithContext,
|
|
126
414
|
getFileList,
|
|
127
415
|
loadFileTurns,
|
|
128
416
|
listHistoryFiles,
|
|
417
|
+
|
|
418
|
+
// session-level (NEW)
|
|
419
|
+
startSession,
|
|
420
|
+
endSession,
|
|
421
|
+
listSessions,
|
|
422
|
+
getSession,
|
|
423
|
+
loadSessionTurns,
|
|
424
|
+
searchSessions,
|
|
425
|
+
globalSearch,
|
|
426
|
+
|
|
427
|
+
// migration
|
|
428
|
+
migrateLegacySessions,
|
|
429
|
+
|
|
430
|
+
// paths
|
|
129
431
|
MEM_DIR,
|
|
130
432
|
};
|
package/Mem/index.js
CHANGED
|
@@ -1,11 +1,67 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const os = require("os");
|
|
4
|
+
|
|
5
|
+
const MEM_DIR = path.join(os.homedir(), ".clinn", "mem");
|
|
6
|
+
const PERSIST_FILE = path.join(MEM_DIR, "persistent-memory.json");
|
|
7
|
+
|
|
8
|
+
function _ensureDir() {
|
|
9
|
+
if (!fs.existsSync(MEM_DIR)) fs.mkdirSync(MEM_DIR, { recursive: true });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function _loadPersist() {
|
|
13
|
+
_ensureDir();
|
|
14
|
+
try {
|
|
15
|
+
if (!fs.existsSync(PERSIST_FILE)) return [];
|
|
16
|
+
const raw = fs.readFileSync(PERSIST_FILE, "utf-8");
|
|
17
|
+
if (!raw.trim()) return [];
|
|
18
|
+
return JSON.parse(raw);
|
|
19
|
+
} catch (_) {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function _savePersist(entries) {
|
|
25
|
+
_ensureDir();
|
|
26
|
+
try {
|
|
27
|
+
const clean = entries
|
|
28
|
+
.slice(-500)
|
|
29
|
+
.map(({ id, text, tags, createdAt }) => ({
|
|
30
|
+
id,
|
|
31
|
+
time: new Date(createdAt).toISOString(),
|
|
32
|
+
content: text.slice(0, 300),
|
|
33
|
+
tags: tags || [],
|
|
34
|
+
}));
|
|
35
|
+
fs.writeFileSync(PERSIST_FILE, JSON.stringify(clean, null, 2), "utf-8");
|
|
36
|
+
} catch (_) {}
|
|
37
|
+
}
|
|
38
|
+
|
|
1
39
|
class ConversationMemory {
|
|
2
40
|
constructor(options = {}) {
|
|
3
41
|
this.maxHistory = options.maxHistory || 30;
|
|
4
42
|
this.maxEntries = options.maxEntries || 800;
|
|
5
43
|
this.maxEntryChars = options.maxEntryChars || 200;
|
|
6
44
|
this.history = [];
|
|
7
|
-
|
|
45
|
+
|
|
46
|
+
// ROM: 持久化记忆 (落盘)
|
|
47
|
+
this.romEntries = [];
|
|
48
|
+
// RAM: 会话记忆 (仅本次窗口)
|
|
49
|
+
this.ramEntries = [];
|
|
8
50
|
this._idCounter = 0;
|
|
51
|
+
|
|
52
|
+
// 从磁盘加载 ROM
|
|
53
|
+
const persisted = _loadPersist();
|
|
54
|
+
for (const p of persisted) {
|
|
55
|
+
this.romEntries.push({
|
|
56
|
+
id: ++this._idCounter,
|
|
57
|
+
text: p.content || "",
|
|
58
|
+
tags: p.tags || [],
|
|
59
|
+
createdAt: p.time ? new Date(p.time).getTime() : Date.now(),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (this.romEntries.length > this.maxEntries) {
|
|
63
|
+
this.romEntries = this.romEntries.slice(-this.maxEntries);
|
|
64
|
+
}
|
|
9
65
|
}
|
|
10
66
|
|
|
11
67
|
addUser(content) {
|
|
@@ -32,7 +88,9 @@ class ConversationMemory {
|
|
|
32
88
|
this.maxHistory = Math.max(2, Math.min(n, 200));
|
|
33
89
|
}
|
|
34
90
|
|
|
35
|
-
|
|
91
|
+
// ── ROM (永久保存, 落盘) ──
|
|
92
|
+
|
|
93
|
+
addRomEntry(content, tags = []) {
|
|
36
94
|
const text = String(content).slice(0, this.maxEntryChars);
|
|
37
95
|
if (!text.trim()) return null;
|
|
38
96
|
const entry = {
|
|
@@ -41,17 +99,42 @@ class ConversationMemory {
|
|
|
41
99
|
tags,
|
|
42
100
|
createdAt: Date.now(),
|
|
43
101
|
};
|
|
44
|
-
this.
|
|
45
|
-
if (this.
|
|
46
|
-
this.
|
|
102
|
+
this.romEntries.push(entry);
|
|
103
|
+
if (this.romEntries.length > this.maxEntries) {
|
|
104
|
+
this.romEntries.shift();
|
|
47
105
|
}
|
|
106
|
+
_savePersist(this.romEntries);
|
|
48
107
|
return entry;
|
|
49
108
|
}
|
|
50
109
|
|
|
110
|
+
// ── RAM (本次窗口, 不落盘) ──
|
|
111
|
+
|
|
112
|
+
addRamEntry(content, tags = []) {
|
|
113
|
+
const text = String(content).slice(0, this.maxEntryChars);
|
|
114
|
+
if (!text.trim()) return null;
|
|
115
|
+
const entry = {
|
|
116
|
+
id: ++this._idCounter,
|
|
117
|
+
text,
|
|
118
|
+
tags,
|
|
119
|
+
createdAt: Date.now(),
|
|
120
|
+
};
|
|
121
|
+
this.ramEntries.push(entry);
|
|
122
|
+
if (this.ramEntries.length > this.maxEntries) {
|
|
123
|
+
this.ramEntries.shift();
|
|
124
|
+
}
|
|
125
|
+
return entry;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── 搜索 (搜 ROM + RAM) ──
|
|
129
|
+
|
|
51
130
|
searchEntries(query, limit = 5) {
|
|
52
|
-
|
|
131
|
+
const all = [
|
|
132
|
+
...this.romEntries.map(e => ({ ...e, _type: "rom" })),
|
|
133
|
+
...this.ramEntries.map(e => ({ ...e, _type: "ram" })),
|
|
134
|
+
];
|
|
135
|
+
if (!query || !query.trim()) return all.slice(-limit);
|
|
53
136
|
const q = query.toLowerCase();
|
|
54
|
-
const scored =
|
|
137
|
+
const scored = all.map((e) => {
|
|
55
138
|
const lower = e.text.toLowerCase();
|
|
56
139
|
let score = 0;
|
|
57
140
|
if (lower === q) score = 100;
|
|
@@ -71,24 +154,48 @@ class ConversationMemory {
|
|
|
71
154
|
}
|
|
72
155
|
|
|
73
156
|
getAllEntries() {
|
|
74
|
-
return [
|
|
157
|
+
return [
|
|
158
|
+
...this.romEntries.map(e => ({ ...e, _type: "rom" })),
|
|
159
|
+
...this.ramEntries.map(e => ({ ...e, _type: "ram" })),
|
|
160
|
+
];
|
|
75
161
|
}
|
|
76
162
|
|
|
77
163
|
getEntryCount() {
|
|
78
|
-
return this.
|
|
164
|
+
return this.romEntries.length + this.ramEntries.length;
|
|
79
165
|
}
|
|
80
166
|
|
|
167
|
+
// ── 删除 (搜 ROM + RAM) ──
|
|
168
|
+
|
|
81
169
|
removeEntry(id) {
|
|
82
|
-
|
|
170
|
+
let idx = this.romEntries.findIndex((e) => e.id === id);
|
|
83
171
|
if (idx !== -1) {
|
|
84
|
-
this.
|
|
172
|
+
this.romEntries.splice(idx, 1);
|
|
173
|
+
_savePersist(this.romEntries);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
idx = this.ramEntries.findIndex((e) => e.id === id);
|
|
177
|
+
if (idx !== -1) {
|
|
178
|
+
this.ramEntries.splice(idx, 1);
|
|
85
179
|
return true;
|
|
86
180
|
}
|
|
87
181
|
return false;
|
|
88
182
|
}
|
|
89
183
|
|
|
90
|
-
|
|
91
|
-
|
|
184
|
+
// ── 清空 ──
|
|
185
|
+
|
|
186
|
+
clearRamEntries() {
|
|
187
|
+
this.ramEntries = [];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
clearRomEntries() {
|
|
191
|
+
this.romEntries = [];
|
|
192
|
+
_savePersist(this.romEntries);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
clearAllEntries() {
|
|
196
|
+
this.romEntries = [];
|
|
197
|
+
this.ramEntries = [];
|
|
198
|
+
_savePersist(this.romEntries);
|
|
92
199
|
}
|
|
93
200
|
|
|
94
201
|
compressHistory(agentInstance) {
|
|
@@ -104,7 +211,9 @@ class ConversationMemory {
|
|
|
104
211
|
stats() {
|
|
105
212
|
return {
|
|
106
213
|
historyMessages: this.history.length,
|
|
107
|
-
|
|
214
|
+
romEntries: this.romEntries.length,
|
|
215
|
+
ramEntries: this.ramEntries.length,
|
|
216
|
+
entries: this.romEntries.length + this.ramEntries.length,
|
|
108
217
|
maxEntries: this.maxEntries,
|
|
109
218
|
};
|
|
110
219
|
}
|