persyst-mcp 2.2.4 → 2.2.6

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/src/watcher.js CHANGED
@@ -8,23 +8,26 @@
8
8
 
9
9
  import { join, resolve } from 'path';
10
10
  import { homedir } from 'os';
11
- import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
11
+ import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, openSync, readSync, closeSync } from 'fs';
12
12
  import {
13
13
  getWatchPosition,
14
14
  upsertWatchPosition,
15
15
  insertMemory,
16
16
  insertVector,
17
- memoryExists
17
+ memoryExists,
18
+ deleteMemory
18
19
  } from './database.js';
19
20
  import { generateEmbedding } from './embeddings.js';
20
- import { extractHeuristic } from './extractor-heuristic.js';
21
+ import { extractHeuristic, hasExtractableSignals } from './extractor-heuristic.js';
21
22
  import { searchHybrid } from './search.js';
22
23
  import { searchCache } from './cache.js';
24
+ import { memoryEventBus } from './events.js';
25
+ import chokidar from 'chokidar';
23
26
 
24
- // Config path: ~/.persyst/config.json
25
- const CONFIG_FILE = join(homedir(), '.persyst', 'config.json');
27
+ // Config path: ~/.persyst/config.json (overridable for tests)
28
+ const CONFIG_FILE = process.env.PERSYST_CONFIG_FILE || join(homedir(), '.persyst', 'config.json');
26
29
 
27
- let intervalId = null;
30
+ let chokidarWatcher = null;
28
31
  const DEDUP_THRESHOLD = 0.80;
29
32
 
30
33
  /**
@@ -86,25 +89,47 @@ async function processJsonlFile(filePath) {
86
89
 
87
90
  if (stat.size <= lastPos) return;
88
91
 
89
- // Read only new content appended to the file
90
- const fileBuffer = readFileSync(filePath);
91
- const newContentBuffer = fileBuffer.subarray(lastPos, stat.size);
92
- const text = newContentBuffer.toString('utf8');
92
+ // Read only new content appended to the file (Bug C fix)
93
+ const length = stat.size - lastPos;
94
+ let text = '';
95
+ if (length > 0) {
96
+ const newContentBuffer = Buffer.alloc(length);
97
+ const fd = openSync(filePath, 'r');
98
+ try {
99
+ readSync(fd, newContentBuffer, 0, length, lastPos);
100
+ } finally {
101
+ closeSync(fd);
102
+ }
103
+ text = newContentBuffer.toString('utf8');
104
+ }
93
105
 
94
106
  const lines = text.split('\n');
95
107
  let addedCount = 0;
108
+ let processedOffset = lastPos;
96
109
 
97
- for (const line of lines) {
98
- if (!line.trim()) continue;
110
+ for (let i = 0; i < lines.length; i++) {
111
+ const line = lines[i];
112
+ const isLastLine = i === lines.length - 1;
113
+
114
+ // Empty trailing line after a newline is expected; skip it without treating it as partial.
115
+ if (!line.trim()) {
116
+ if (!isLastLine) processedOffset += line.length + 1;
117
+ continue;
118
+ }
99
119
 
100
120
  let record;
101
121
  try {
102
122
  record = JSON.parse(line);
103
123
  } catch (_) {
104
- // Line might be incomplete/partially written skip and parse next time
124
+ // If the last line fails to parse, it may be partially written. Leave processedOffset
125
+ // before this line so the next scan re-reads it from the start.
126
+ if (!isLastLine) processedOffset += line.length + 1;
105
127
  continue;
106
128
  }
107
129
 
130
+ // Commit the bytes for this line (including the newline that produced the split).
131
+ processedOffset += line.length + 1;
132
+
108
133
  // Check if it's user prompt or assistant response
109
134
  if (
110
135
  record.content &&
@@ -112,30 +137,39 @@ async function processJsonlFile(filePath) {
112
137
  ) {
113
138
  // Strip XML/markdown wrapper tags (like <USER_REQUEST> or <ADDITIONAL_METADATA>)
114
139
  const cleanText = record.content.replace(/<[^>]+>[\s\S]*?<\/[^>]+>/g, '').trim();
115
- if (cleanText.length < 15) continue;
140
+ if (cleanText.length < 15 || !hasExtractableSignals(cleanText)) continue;
116
141
 
117
142
  const facts = extractHeuristic(cleanText);
118
143
  for (const fact of facts) {
119
- // Verify against exact duplicate
120
- if (memoryExists(fact.content)) continue;
144
+ // Verify against exact duplicate (Bug A fix: check namespace 'shared')
145
+ if (memoryExists(fact.content, 'shared')) continue;
121
146
 
122
- // Verify against semantic similarity
123
- const similar = await searchHybrid(fact.content, 1);
147
+ // Verify against semantic similarity (Bug B fix: check namespace 'shared')
148
+ const similar = await searchHybrid(fact.content, 1, null, null, 'shared');
124
149
  if (similar.length > 0 && parseFloat(similar[0].similarity) >= DEDUP_THRESHOLD) {
125
150
  continue;
126
151
  }
127
152
 
128
- // Insert memory with provenance
153
+ // Insert memory with provenance (written to 'shared' by default)
129
154
  const id = insertMemory(fact.content, fact.confidence, {
130
155
  source_type: 'agent',
131
156
  source_id: record.source === 'MODEL' ? 'antigravity-worker' : 'user-dialogue',
132
157
  confidence: fact.confidence
133
158
  });
134
159
 
135
- const embedding = await generateEmbedding(fact.content);
136
- insertVector(id, embedding);
160
+ try {
161
+ const embedding = await generateEmbedding(fact.content);
162
+ insertVector(id, embedding);
163
+ } catch (embedErr) {
164
+ console.error(`[persyst-watcher] Embedding failed for fact #${id}: ${embedErr.message}`);
165
+ // Clean up: delete the memory so we don't have orphaned entries
166
+ try { deleteMemory(id); } catch (_) {}
167
+ continue;
168
+ }
169
+
137
170
  addedCount++;
138
171
  console.error(`[persyst-watcher] Auto-extracted fact: "${fact.content}" (Memory #${id})`);
172
+ memoryEventBus.emit('memory_added', { id, content: fact.content, namespace: 'shared', source: 'watcher-antigravity' });
139
173
  }
140
174
  }
141
175
  }
@@ -144,10 +178,13 @@ async function processJsonlFile(filePath) {
144
178
  searchCache.invalidate();
145
179
  }
146
180
 
147
- // Persist new byte offset position
148
- upsertWatchPosition(filePath, stat.size);
181
+ // Persist the byte offset up to the last successfully parsed complete line.
182
+ // Do not advance past an incomplete trailing line so it is re-read on the next scan.
183
+ upsertWatchPosition(filePath, processedOffset);
184
+ return addedCount;
149
185
  } catch (err) {
150
186
  console.error(`[persyst-watcher] Failed to process JSONL file ${filePath}: ${err.message}`);
187
+ return 0;
151
188
  }
152
189
  }
153
190
 
@@ -175,29 +212,41 @@ async function processJsonFile(filePath) {
175
212
  // Process only newly added messages
176
213
  for (let i = lastMsgCount; i < history.length; i++) {
177
214
  const msg = history[i];
178
- if (!msg.content || typeof msg.content !== 'string') continue;
215
+ if (!msg.content || typeof msg.content !== 'string' || !hasExtractableSignals(msg.content)) continue;
179
216
 
180
217
  // Filter out system message structures
181
218
  if (msg.role === 'user' || msg.role === 'assistant') {
182
219
  const facts = extractHeuristic(msg.content);
183
220
  for (const fact of facts) {
184
- if (memoryExists(fact.content)) continue;
221
+ // Verify against exact duplicate (Bug A fix: check namespace 'shared')
222
+ if (memoryExists(fact.content, 'shared')) continue;
185
223
 
186
- const similar = await searchHybrid(fact.content, 1);
224
+ // Verify against semantic similarity (Bug B fix: check namespace 'shared')
225
+ const similar = await searchHybrid(fact.content, 1, null, null, 'shared');
187
226
  if (similar.length > 0 && parseFloat(similar[0].similarity) >= DEDUP_THRESHOLD) {
188
227
  continue;
189
228
  }
190
229
 
230
+ // Insert memory with provenance (written to 'shared' by default)
191
231
  const id = insertMemory(fact.content, fact.confidence, {
192
232
  source_type: 'agent',
193
233
  source_id: msg.role === 'assistant' ? 'roo-worker' : 'user-dialogue',
194
234
  confidence: fact.confidence
195
235
  });
196
236
 
197
- const embedding = await generateEmbedding(fact.content);
198
- insertVector(id, embedding);
237
+ try {
238
+ const embedding = await generateEmbedding(fact.content);
239
+ insertVector(id, embedding);
240
+ } catch (embedErr) {
241
+ console.error(`[persyst-watcher] Embedding failed for fact #${id}: ${embedErr.message}`);
242
+ // Clean up: delete the memory so we don't have orphaned entries
243
+ try { deleteMemory(id); } catch (_) {}
244
+ continue;
245
+ }
246
+
199
247
  addedCount++;
200
248
  console.error(`[persyst-watcher] Auto-extracted fact: "${fact.content}" (Memory #${id})`);
249
+ memoryEventBus.emit('memory_added', { id, content: fact.content, namespace: 'shared', source: 'watcher-roo' });
201
250
  }
202
251
  }
203
252
  }
@@ -208,8 +257,10 @@ async function processJsonFile(filePath) {
208
257
 
209
258
  // Persist message count index
210
259
  upsertWatchPosition(filePath, history.length);
260
+ return addedCount;
211
261
  } catch (err) {
212
262
  console.error(`[persyst-watcher] Failed to process JSON file ${filePath}: ${err.message}`);
263
+ return 0;
213
264
  }
214
265
  }
215
266
 
@@ -248,6 +299,7 @@ function findFiles(dir, ext, depth = 3) {
248
299
  */
249
300
  export async function scanDirectories() {
250
301
  const watchDirs = loadWatchedDirs();
302
+ let totalAdded = 0;
251
303
 
252
304
  for (const dir of watchDirs) {
253
305
  if (!existsSync(dir)) continue;
@@ -255,7 +307,7 @@ export async function scanDirectories() {
255
307
  // Scan for JSONL (Antigravity transcripts)
256
308
  const jsonlFiles = findFiles(dir, 'transcript.jsonl', 3);
257
309
  for (const file of jsonlFiles) {
258
- await processJsonlFile(file);
310
+ totalAdded += await processJsonlFile(file);
259
311
  }
260
312
 
261
313
  // Scan for JSON (Roo Code / Cline task files)
@@ -263,44 +315,116 @@ export async function scanDirectories() {
263
315
  for (const file of jsonFiles) {
264
316
  // Avoid processing general configurations/settings files
265
317
  if (file.includes('tasks')) {
266
- await processJsonFile(file);
318
+ totalAdded += await processJsonFile(file);
267
319
  }
268
320
  }
269
321
  }
322
+
323
+ // Auto-consolidate memories if new ones were added to keep prompt context slim
324
+ if (totalAdded > 0) {
325
+ try {
326
+ console.error(`[persyst-watcher] Running automatic memory consolidation sweep...`);
327
+ const { consolidateMemories } = await import('./search.js');
328
+ const report = await consolidateMemories();
329
+ console.error(`[persyst-watcher] Auto-consolidation complete: merged ${report.consolidated_groups} duplicate groups.`);
330
+ } catch (e) {
331
+ console.error(`[persyst-watcher] Auto-consolidation failed: ${e.message}`);
332
+ }
333
+ }
334
+
335
+ // Run periodic auto-expiry check on every folder scan (fast query)
336
+ try {
337
+ const { archiveExpiredMemories } = await import('./database.js');
338
+ archiveExpiredMemories();
339
+ } catch (e) {
340
+ console.error(`[persyst-watcher] Auto-expiry execution failed: ${e.message}`);
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Handle a file addition or modification event from Chokidar.
346
+ * @param {string} filePath
347
+ */
348
+ async function handleFileChange(filePath) {
349
+ const normalizedPath = filePath.replace(/\\/g, '/');
350
+ let addedCount = 0;
351
+
352
+ if (normalizedPath.endsWith('transcript.jsonl')) {
353
+ addedCount = await processJsonlFile(filePath);
354
+ } else if (normalizedPath.endsWith('.json') && normalizedPath.includes('tasks')) {
355
+ addedCount = await processJsonFile(filePath);
356
+ }
357
+
358
+ if (addedCount > 0) {
359
+ try {
360
+ console.error(`[persyst-watcher] Running automatic memory consolidation sweep...`);
361
+ const { consolidateMemories } = await import('./search.js');
362
+ const report = await consolidateMemories();
363
+ console.error(`[persyst-watcher] Auto-consolidation complete: merged ${report.consolidated_groups} duplicate groups.`);
364
+ } catch (e) {
365
+ console.error(`[persyst-watcher] Auto-consolidation failed: ${e.message}`);
366
+ }
367
+ }
368
+
369
+ // Run periodic auto-expiry check on every change (fast query)
370
+ try {
371
+ const { archiveExpiredMemories } = await import('./database.js');
372
+ archiveExpiredMemories();
373
+ } catch (e) {
374
+ console.error(`[persyst-watcher] Auto-expiry execution failed: ${e.message}`);
375
+ }
270
376
  }
271
377
 
272
378
  /**
273
379
  * Start the background log watcher daemon.
274
380
  */
275
381
  export function startWatcher() {
276
- if (intervalId) return;
277
-
278
- console.error('[persyst-watcher] Starting background log watcher daemon...');
279
- // Warm up config/paths
280
- loadWatchedDirs();
382
+ if (chokidarWatcher) return;
281
383
 
282
- // Run initial scan
283
- scanDirectories().catch(err => {
284
- console.error(`[persyst-watcher] Initial scan failed: ${err.message}`);
285
- });
384
+ console.error('[persyst-watcher] Starting background log watcher daemon (Chokidar)...');
385
+ const watchDirs = loadWatchedDirs();
286
386
 
287
- // Polling directory scan every 5 seconds
288
- intervalId = setInterval(async () => {
289
- try {
290
- await scanDirectories();
291
- } catch (err) {
292
- console.error(`[persyst-watcher] Folder scan failed: ${err.message}`);
293
- }
294
- }, 5000);
387
+ // Run initial scan, then start watching
388
+ scanDirectories()
389
+ .catch(err => {
390
+ console.error(`[persyst-watcher] Initial scan failed: ${err.message}`);
391
+ })
392
+ .then(() => {
393
+ if (chokidarWatcher) return;
394
+ chokidarWatcher = chokidar.watch(watchDirs, {
395
+ persistent: true,
396
+ ignoreInitial: true, // we already ran scanDirectories
397
+ awaitWriteFinish: {
398
+ stabilityThreshold: 300,
399
+ pollInterval: 100
400
+ }
401
+ });
402
+
403
+ chokidarWatcher.on('add', filePath => {
404
+ handleFileChange(filePath).catch(err => {
405
+ console.error(`[persyst-watcher] Error handling added file ${filePath}:`, err);
406
+ });
407
+ });
408
+
409
+ chokidarWatcher.on('change', filePath => {
410
+ handleFileChange(filePath).catch(err => {
411
+ console.error(`[persyst-watcher] Error handling changed file ${filePath}:`, err);
412
+ });
413
+ });
414
+
415
+ chokidarWatcher.on('error', err => {
416
+ console.error(`[persyst-watcher] Chokidar watcher error: ${err.message}`);
417
+ });
418
+ });
295
419
  }
296
420
 
297
421
  /**
298
422
  * Stop the background log watcher daemon.
299
423
  */
300
424
  export function stopWatcher() {
301
- if (intervalId) {
302
- clearInterval(intervalId);
303
- intervalId = null;
425
+ if (chokidarWatcher) {
426
+ chokidarWatcher.close().catch(() => {});
427
+ chokidarWatcher = null;
304
428
  console.error('[persyst-watcher] Background log watcher daemon stopped.');
305
429
  }
306
430
  }