@ghenya/clinn 0.8.8 → 0.9.1

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.
@@ -2,6 +2,6 @@
2
2
  ██ ██ ██ ████ ██ ████ ██
3
3
  ██ ██ ██ ██ ██ ██ ██ ██ ██
4
4
  ██ ██ ██ ██ ██ ██ ██ ██ ██
5
- ██████ ███████ ██ ██ ████ ██ █0.8.8
5
+ ██████ ███████ ██ ██ ████ ██ █0.8.3
6
6
 
7
7
 
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 saveTurn(userMessage, assistantResponse, cwd, allMessages) {
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 userLower = (r.user || "").toLowerCase();
84
- const assistantLower = (r.assistant || "").toLowerCase();
85
- const msgLower = JSON.stringify(r.messages || []).toLowerCase();
86
- if (userLower.includes(q) || assistantLower.includes(q) || msgLower.includes(q)) {
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
- return results;
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
- function getFileList() {
102
- const files = listHistoryFiles();
103
- return files.map((f) => {
104
- const filePath = path.join(MEM_DIR, f);
105
- const records = loadFile(filePath);
106
- const stat = fs.statSync(filePath);
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
- file: f,
109
- turns: records.length,
110
- size: Math.round(stat.size / 1024),
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
- function loadFileTurns(fileName, limit = 50) {
117
- const filePath = path.join(MEM_DIR, fileName);
118
- const records = loadFile(filePath);
119
- return records.slice(-limit);
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
- this.entries = [];
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
- addEntry(content, tags = []) {
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.entries.push(entry);
45
- if (this.entries.length > this.maxEntries) {
46
- this.entries.shift();
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
- if (!query || !query.trim()) return this.entries.slice(-limit);
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 = this.entries.map((e) => {
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 [...this.entries];
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.entries.length;
164
+ return this.romEntries.length + this.ramEntries.length;
79
165
  }
80
166
 
167
+ // ── 删除 (搜 ROM + RAM) ──
168
+
81
169
  removeEntry(id) {
82
- const idx = this.entries.findIndex((e) => e.id === id);
170
+ let idx = this.romEntries.findIndex((e) => e.id === id);
83
171
  if (idx !== -1) {
84
- this.entries.splice(idx, 1);
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
- clearEntries() {
91
- this.entries = [];
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
- entries: this.entries.length,
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
  }