persyst-mcp 2.1.3 → 2.2.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/src/server.js CHANGED
@@ -1,67 +1,72 @@
1
- /**
2
- * server.js — MCP Server Setup
3
- *
4
- * Creates the MCP server, registers all tools, and connects
5
- * via stdio transport (the standard MCP communication method).
6
- * Sets up hourly temporal decay and daily consolidation background tasks.
7
- *
8
- * IMPORTANT: Never write to stdout — it's reserved for MCP protocol.
9
- * All logging goes to stderr via console.error().
10
- */
11
-
12
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
- import { registerTools, cleanupWatchers } from './tools.js';
15
- import { applyTemporalDecay, closeDatabase } from './database.js';
16
- import { consolidateMemories } from './search.js';
17
-
18
- /**
19
- * Start the Persyst MCP server.
20
- * This is called from index.js (the entry point).
21
- */
22
- export async function startServer() {
23
- // --- Create MCP server ---
24
- const server = new McpServer({
25
- name: 'persyst',
26
- version: '2.1.3'
27
- });
28
-
29
- // --- Register all tools ---
30
- const registeredCount = registerTools(server);
31
- console.error(`[persyst] ${registeredCount} tools registered ✓`);
32
-
33
- // --- Start temporal decay timer ---
34
- // Runs every hour: reduces importance of memories not accessed in 7+ days
35
- const decayTimer = setInterval(applyTemporalDecay, 3600000);
36
-
37
- // --- Start daily consolidation sweep ---
38
- // Runs every 24 hours: merges similar memories (similarity > 0.85)
39
- const consolidationTimer = setInterval(async () => {
40
- console.error('[persyst] Running scheduled daily memory consolidation sweep...');
41
- try {
42
- const report = await consolidateMemories();
43
- console.error(`[persyst] Consolidation sweep completed: consolidated ${report.consolidated_groups} duplicate groups.`);
44
- } catch (err) {
45
- console.error('[persyst] Daily consolidation sweep failed:', err.message);
46
- }
47
- }, 86400000);
48
-
49
- // --- Graceful shutdown (Bug 3 fix: also cleans up git watchers) ---
50
- const shutdown = () => {
51
- console.error('[persyst] Shutting down...');
52
- clearInterval(decayTimer);
53
- clearInterval(consolidationTimer);
54
- cleanupWatchers(); // Bug 3 fix: stop all git repo watchers
55
- closeDatabase();
56
- process.exit(0);
57
- };
58
- process.on('SIGINT', shutdown);
59
- process.on('SIGTERM', shutdown);
60
-
61
- // --- Connect via stdio ---
62
- const transport = new StdioServerTransport();
63
- await server.connect(transport);
64
-
65
- console.error('[persyst] MCP server running on stdio ✓');
66
- console.error('[persyst] Ready to receive tool calls');
67
- }
1
+ /**
2
+ * server.js — MCP Server Setup
3
+ *
4
+ * Creates the MCP server, registers all tools, and connects
5
+ * via stdio transport (the standard MCP communication method).
6
+ * Sets up hourly temporal decay and daily consolidation background tasks.
7
+ *
8
+ * IMPORTANT: Never write to stdout — it's reserved for MCP protocol.
9
+ * All logging goes to stderr via console.error().
10
+ */
11
+
12
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
+ import { registerTools, cleanupWatchers } from './tools.js';
15
+ import { applyTemporalDecay, closeDatabase } from './database.js';
16
+ import { consolidateMemories } from './search.js';
17
+ import { startWatcher, stopWatcher } from './watcher.js';
18
+
19
+ /**
20
+ * Start the Persyst MCP server.
21
+ * This is called from index.js (the entry point).
22
+ */
23
+ export async function startServer() {
24
+ // --- Create MCP server ---
25
+ const server = new McpServer({
26
+ name: 'persyst',
27
+ version: '2.1.3'
28
+ });
29
+
30
+ // --- Register all tools ---
31
+ const registeredCount = registerTools(server);
32
+ console.error(`[persyst] ${registeredCount} tools registered ✓`);
33
+
34
+ // --- Start background log watcher daemon ---
35
+ startWatcher();
36
+
37
+ // --- Start temporal decay timer ---
38
+ // Runs every hour: reduces importance of memories not accessed in 7+ days
39
+ const decayTimer = setInterval(applyTemporalDecay, 3600000);
40
+
41
+ // --- Start daily consolidation sweep ---
42
+ // Runs every 24 hours: merges similar memories (similarity > 0.85)
43
+ const consolidationTimer = setInterval(async () => {
44
+ console.error('[persyst] Running scheduled daily memory consolidation sweep...');
45
+ try {
46
+ const report = await consolidateMemories();
47
+ console.error(`[persyst] Consolidation sweep completed: consolidated ${report.consolidated_groups} duplicate groups.`);
48
+ } catch (err) {
49
+ console.error('[persyst] Daily consolidation sweep failed:', err.message);
50
+ }
51
+ }, 86400000);
52
+
53
+ // --- Graceful shutdown (Bug 3 fix: also cleans up git watchers) ---
54
+ const shutdown = () => {
55
+ console.error('[persyst] Shutting down...');
56
+ clearInterval(decayTimer);
57
+ clearInterval(consolidationTimer);
58
+ stopWatcher(); // Stop background log watcher
59
+ cleanupWatchers(); // Bug 3 fix: stop all git repo watchers
60
+ closeDatabase();
61
+ process.exit(0);
62
+ };
63
+ process.on('SIGINT', shutdown);
64
+ process.on('SIGTERM', shutdown);
65
+
66
+ // --- Connect via stdio ---
67
+ const transport = new StdioServerTransport();
68
+ await server.connect(transport);
69
+
70
+ console.error('[persyst] MCP server running on stdio ✓');
71
+ console.error('[persyst] Ready to receive tool calls');
72
+ }
package/src/tools.js CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { z } from 'zod';
15
15
  import { generateEmbedding } from './embeddings.js';
16
- import {
16
+ import db, {
17
17
  insertMemory,
18
18
  insertVector,
19
19
  getMemory,
@@ -32,6 +32,7 @@ import {
32
32
  getMemoryByContent,
33
33
  boostMemory,
34
34
  logContradiction,
35
+ getProvenance,
35
36
  getAllAgentStats,
36
37
  getAttestationsByDateRange,
37
38
  getMemoryHistoryChain,
@@ -174,22 +175,54 @@ export function registerTools(server) {
174
175
  const existingMemory = getMemoryById(hitId, namespace);
175
176
  if (!existingMemory) continue;
176
177
 
177
- // Check if content is substantially different (Jaccard distance > 0.5)
178
178
  const jaccard = jaccardDistance(content, existingMemory.content);
179
- if (jaccard > 0.5) {
180
- // This is a contradiction: similar topic, different content
181
- logContradiction(hitId, id, `Auto-detected contradiction (similarity: ${sim.toFixed(3)}, content_diff: ${jaccard.toFixed(3)})`);
182
- contradictions.push({
183
- old_memory_id: hitId,
184
- old_content_preview: existingMemory.content.slice(0, 100),
185
- similarity: sim.toFixed(4),
186
- content_difference: jaccard.toFixed(4)
187
- });
179
+ // Contradiction: similar topic (high similarity), but differing key terms
180
+ if (jaccard > 0 && jaccard < 0.5) {
181
+ // Fetch provenances for trust calculation
182
+ const oldProv = getProvenance(hitId);
183
+ let oldReputation = 1.0;
184
+ if (oldProv && oldProv.source_type === 'agent' && oldProv.source_id) {
185
+ const agentRow = db.prepare('SELECT reputation_score FROM agent_stats WHERE agent_id = ?').get(oldProv.source_id);
186
+ if (agentRow) oldReputation = agentRow.reputation_score;
187
+ }
188
+
189
+ let newReputation = 1.0;
190
+ if (agent_id) {
191
+ const agentRow = db.prepare('SELECT reputation_score FROM agent_stats WHERE agent_id = ?').get(agent_id);
192
+ if (agentRow) newReputation = agentRow.reputation_score;
193
+ }
194
+
195
+ const trustOld = (oldProv ? oldProv.confidence : 1.0) * oldReputation;
196
+ const trustNew = 1.0 * newReputation; // New confidence is 1.0
197
+
198
+ const isSelfUpdate = oldProv && oldProv.source_type === 'agent' && oldProv.source_id === agent_id;
199
+
200
+ if (isSelfUpdate || trustNew > trustOld) {
201
+ // New is preferred
202
+ logContradiction(hitId, id, `Auto-detected contradiction: new memory is more trustworthy (similarity: ${sim.toFixed(3)}, content_diff: ${jaccard.toFixed(3)})`);
203
+ contradictions.push({
204
+ old_memory_id: hitId,
205
+ old_content_preview: existingMemory.content.slice(0, 100),
206
+ similarity: sim.toFixed(4),
207
+ content_difference: jaccard.toFixed(4),
208
+ resolution: 'replaced_old'
209
+ });
210
+ } else {
211
+ // Old is preferred
212
+ logContradiction(id, hitId, `Auto-detected contradiction: existing memory is more trustworthy (similarity: ${sim.toFixed(3)}, content_diff: ${jaccard.toFixed(3)})`);
213
+ contradictions.push({
214
+ old_memory_id: hitId,
215
+ old_content_preview: existingMemory.content.slice(0, 100),
216
+ similarity: sim.toFixed(4),
217
+ content_difference: jaccard.toFixed(4),
218
+ resolution: 'kept_old'
219
+ });
220
+ break; // New memory was archived, stop contradiction check
221
+ }
188
222
  }
189
223
  }
190
224
  }
191
225
  } catch (e) {
192
- // Contradiction detection is best-effort, don't fail the memory insertion
193
226
  console.error(`[persyst] Contradiction detection error: ${e.message}`);
194
227
  }
195
228
 
@@ -499,7 +532,19 @@ export function registerTools(server) {
499
532
  },
500
533
  async ({ query }) => {
501
534
  try {
502
- const hits = searchAllMemoriesFts(query, 5);
535
+ let hits = [];
536
+ const queryAsId = Number(query);
537
+ if (!isNaN(queryAsId) && Number.isInteger(queryAsId)) {
538
+ const mem = getAnyMemoryById(queryAsId);
539
+ if (mem) {
540
+ hits.push({ id: mem.id });
541
+ }
542
+ }
543
+
544
+ if (hits.length === 0) {
545
+ hits = searchAllMemoriesFts(query, 5);
546
+ }
547
+
503
548
  if (hits.length === 0) {
504
549
  return text({ message: 'No memories matching query found.' });
505
550
  }
@@ -507,6 +552,14 @@ export function registerTools(server) {
507
552
  const histories = {};
508
553
  for (const hit of hits) {
509
554
  const chain = getMemoryHistoryChain(hit.id);
555
+ // Decorate chain versions with semantic diffs from the previous version
556
+ for (let idx = 0; idx < chain.length; idx++) {
557
+ if (idx > 0) {
558
+ chain[idx].diff_from_previous = diffWords(chain[idx - 1].content, chain[idx].content);
559
+ } else {
560
+ chain[idx].diff_from_previous = null;
561
+ }
562
+ }
510
563
  histories[hit.id] = chain;
511
564
  }
512
565
 
@@ -716,3 +769,61 @@ function jaccardDistance(a, b) {
716
769
  if (union === 0) return 0;
717
770
  return 1 - (intersection / union);
718
771
  }
772
+
773
+ /**
774
+ * Compute word-level diff between two text strings using dynamic programming.
775
+ * Highlights additions as [+added+] and deletions as [-deleted-].
776
+ * @param {string} oldStr - Original text
777
+ * @param {string} newStr - New version of text
778
+ * @returns {string} Diff string
779
+ */
780
+ function diffWords(oldStr, newStr) {
781
+ const oldWords = oldStr.split(/(\s+)/);
782
+ const newWords = newStr.split(/(\s+)/);
783
+
784
+ const dp = Array(oldWords.length + 1).fill(0).map(() => Array(newWords.length + 1).fill(0));
785
+
786
+ for (let i = 1; i <= oldWords.length; i++) {
787
+ for (let j = 1; j <= newWords.length; j++) {
788
+ if (oldWords[i-1] === newWords[j-1]) {
789
+ dp[i][j] = dp[i-1][j-1] + 1;
790
+ } else {
791
+ dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]);
792
+ }
793
+ }
794
+ }
795
+
796
+ let i = oldWords.length;
797
+ let j = newWords.length;
798
+ const result = [];
799
+
800
+ while (i > 0 || j > 0) {
801
+ if (i > 0 && j > 0 && oldWords[i-1] === newWords[j-1]) {
802
+ result.unshift({ type: 'common', value: oldWords[i-1] });
803
+ i--;
804
+ j--;
805
+ } else if (j > 0 && (i === 0 || dp[i][j-1] >= dp[i-1][j])) {
806
+ result.unshift({ type: 'added', value: newWords[j-1] });
807
+ j--;
808
+ } else {
809
+ result.unshift({ type: 'removed', value: oldWords[i-1] });
810
+ i--;
811
+ }
812
+ }
813
+
814
+ // Combine consecutive items of the same type
815
+ const combined = [];
816
+ for (const part of result) {
817
+ if (combined.length > 0 && combined[combined.length - 1].type === part.type) {
818
+ combined[combined.length - 1].value += part.value;
819
+ } else {
820
+ combined.push({ ...part });
821
+ }
822
+ }
823
+
824
+ return combined.map(part => {
825
+ if (part.type === 'added') return `[+${part.value}+]`;
826
+ if (part.type === 'removed') return `[-${part.value}-]`;
827
+ return part.value;
828
+ }).join('');
829
+ }
package/src/watcher.js ADDED
@@ -0,0 +1,306 @@
1
+ /**
2
+ * watcher.js — Persyst Automatic Log Watcher Daemon
3
+ *
4
+ * Periodically scans configured log directories for coding agents (Antigravity, Roo-Code, etc.),
5
+ * reads new appends/messages from transcripts, runs heuristics to extract high-value memories,
6
+ * and stores them in the local database.
7
+ */
8
+
9
+ import { join, resolve } from 'path';
10
+ import { homedir } from 'os';
11
+ import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
12
+ import {
13
+ getWatchPosition,
14
+ upsertWatchPosition,
15
+ insertMemory,
16
+ insertVector,
17
+ memoryExists
18
+ } from './database.js';
19
+ import { generateEmbedding } from './embeddings.js';
20
+ import { extractHeuristic } from './extractor-heuristic.js';
21
+ import { searchHybrid } from './search.js';
22
+ import { searchCache } from './cache.js';
23
+
24
+ // Config path: ~/.persyst/config.json
25
+ const CONFIG_FILE = join(homedir(), '.persyst', 'config.json');
26
+
27
+ let intervalId = null;
28
+ const DEDUP_THRESHOLD = 0.80;
29
+
30
+ /**
31
+ * Load configured directories to watch. Generates a default config if missing.
32
+ * @returns {Array<string>} List of directory paths to scan
33
+ */
34
+ export function loadWatchedDirs() {
35
+ const defaultDirs = [
36
+ join(homedir(), '.gemini', 'antigravity-ide', 'brain').replace(/\\/g, '/')
37
+ ];
38
+
39
+ // Also probe standard paths for Cline / Roo Code
40
+ const platform = process.platform;
41
+ if (platform === 'win32') {
42
+ const appData = process.env.APPDATA || join(homedir(), 'AppData', 'Roaming');
43
+ const rooPath = join(appData, 'Roo-Code', 'tasks').replace(/\\/g, '/');
44
+ if (existsSync(rooPath)) defaultDirs.push(rooPath);
45
+ const clinePath = join(appData, 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'tasks').replace(/\\/g, '/');
46
+ if (existsSync(clinePath)) defaultDirs.push(clinePath);
47
+ } else if (platform === 'darwin') {
48
+ const rooPath = join(homedir(), 'Library', 'Application Support', 'Roo-Code', 'tasks');
49
+ if (existsSync(rooPath)) defaultDirs.push(rooPath);
50
+ const clinePath = join(homedir(), 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'tasks');
51
+ if (existsSync(clinePath)) defaultDirs.push(clinePath);
52
+ } else {
53
+ const rooPath = join(homedir(), '.config', 'Roo-Code', 'tasks');
54
+ if (existsSync(rooPath)) defaultDirs.push(rooPath);
55
+ const clinePath = join(homedir(), '.config', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'tasks');
56
+ if (existsSync(clinePath)) defaultDirs.push(clinePath);
57
+ }
58
+
59
+ try {
60
+ if (existsSync(CONFIG_FILE)) {
61
+ const config = JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
62
+ if (Array.isArray(config.watch_dirs)) {
63
+ return config.watch_dirs;
64
+ }
65
+ }
66
+ } catch (_) {
67
+ // Fail-safe: rewrite or fallback
68
+ }
69
+
70
+ // Create default config file if it does not exist
71
+ try {
72
+ writeFileSync(CONFIG_FILE, JSON.stringify({ watch_dirs: defaultDirs }, null, 2));
73
+ } catch (_) {}
74
+
75
+ return defaultDirs;
76
+ }
77
+
78
+ /**
79
+ * Scan a single transcript file in JSONL format (Antigravity).
80
+ * @param {string} filePath
81
+ */
82
+ async function processJsonlFile(filePath) {
83
+ try {
84
+ const stat = statSync(filePath);
85
+ const lastPos = getWatchPosition(filePath);
86
+
87
+ if (stat.size <= lastPos) return;
88
+
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');
93
+
94
+ const lines = text.split('\n');
95
+ let addedCount = 0;
96
+
97
+ for (const line of lines) {
98
+ if (!line.trim()) continue;
99
+
100
+ let record;
101
+ try {
102
+ record = JSON.parse(line);
103
+ } catch (_) {
104
+ // Line might be incomplete/partially written — skip and parse next time
105
+ continue;
106
+ }
107
+
108
+ // Check if it's user prompt or assistant response
109
+ if (
110
+ record.content &&
111
+ (record.type === 'USER_INPUT' || record.type === 'PLANNER_RESPONSE' || record.source === 'MODEL')
112
+ ) {
113
+ // Strip XML/markdown wrapper tags (like <USER_REQUEST> or <ADDITIONAL_METADATA>)
114
+ const cleanText = record.content.replace(/<[^>]+>[\s\S]*?<\/[^>]+>/g, '').trim();
115
+ if (cleanText.length < 15) continue;
116
+
117
+ const facts = extractHeuristic(cleanText);
118
+ for (const fact of facts) {
119
+ // Verify against exact duplicate
120
+ if (memoryExists(fact.content)) continue;
121
+
122
+ // Verify against semantic similarity
123
+ const similar = await searchHybrid(fact.content, 1);
124
+ if (similar.length > 0 && parseFloat(similar[0].similarity) >= DEDUP_THRESHOLD) {
125
+ continue;
126
+ }
127
+
128
+ // Insert memory with provenance
129
+ const id = insertMemory(fact.content, fact.confidence, {
130
+ source_type: 'agent',
131
+ source_id: record.source === 'MODEL' ? 'antigravity-worker' : 'user-dialogue',
132
+ confidence: fact.confidence
133
+ });
134
+
135
+ const embedding = await generateEmbedding(fact.content);
136
+ insertVector(id, embedding);
137
+ addedCount++;
138
+ console.error(`[persyst-watcher] Auto-extracted fact: "${fact.content}" (Memory #${id})`);
139
+ }
140
+ }
141
+ }
142
+
143
+ if (addedCount > 0) {
144
+ searchCache.invalidate();
145
+ }
146
+
147
+ // Persist new byte offset position
148
+ upsertWatchPosition(filePath, stat.size);
149
+ } catch (err) {
150
+ console.error(`[persyst-watcher] Failed to process JSONL file ${filePath}: ${err.message}`);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Scan a single task file in JSON format (Roo Code / Cline).
156
+ * @param {string} filePath
157
+ */
158
+ async function processJsonFile(filePath) {
159
+ try {
160
+ const lastMsgCount = getWatchPosition(filePath);
161
+
162
+ // Read full JSON (JSON objects are written entirely, not appended)
163
+ const contentText = readFileSync(filePath, 'utf8');
164
+ let task;
165
+ try {
166
+ task = JSON.parse(contentText);
167
+ } catch (_) {
168
+ return; // incomplete JSON, try again later
169
+ }
170
+
171
+ const history = task.history;
172
+ if (!Array.isArray(history) || history.length <= lastMsgCount) return;
173
+
174
+ let addedCount = 0;
175
+ // Process only newly added messages
176
+ for (let i = lastMsgCount; i < history.length; i++) {
177
+ const msg = history[i];
178
+ if (!msg.content || typeof msg.content !== 'string') continue;
179
+
180
+ // Filter out system message structures
181
+ if (msg.role === 'user' || msg.role === 'assistant') {
182
+ const facts = extractHeuristic(msg.content);
183
+ for (const fact of facts) {
184
+ if (memoryExists(fact.content)) continue;
185
+
186
+ const similar = await searchHybrid(fact.content, 1);
187
+ if (similar.length > 0 && parseFloat(similar[0].similarity) >= DEDUP_THRESHOLD) {
188
+ continue;
189
+ }
190
+
191
+ const id = insertMemory(fact.content, fact.confidence, {
192
+ source_type: 'agent',
193
+ source_id: msg.role === 'assistant' ? 'roo-worker' : 'user-dialogue',
194
+ confidence: fact.confidence
195
+ });
196
+
197
+ const embedding = await generateEmbedding(fact.content);
198
+ insertVector(id, embedding);
199
+ addedCount++;
200
+ console.error(`[persyst-watcher] Auto-extracted fact: "${fact.content}" (Memory #${id})`);
201
+ }
202
+ }
203
+ }
204
+
205
+ if (addedCount > 0) {
206
+ searchCache.invalidate();
207
+ }
208
+
209
+ // Persist message count index
210
+ upsertWatchPosition(filePath, history.length);
211
+ } catch (err) {
212
+ console.error(`[persyst-watcher] Failed to process JSON file ${filePath}: ${err.message}`);
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Find files ending with a given extension recursively in a folder up to a certain depth.
218
+ * @param {string} dir
219
+ * @param {string} ext
220
+ * @param {number} depth
221
+ * @returns {Array<string>}
222
+ */
223
+ function findFiles(dir, ext, depth = 3) {
224
+ const results = [];
225
+ if (depth < 0) return results;
226
+
227
+ try {
228
+ if (!existsSync(dir)) return results;
229
+ const items = readdirSync(dir);
230
+ for (const item of items) {
231
+ const path = join(dir, item);
232
+ let stat;
233
+ try { stat = statSync(path); } catch (_) { continue; }
234
+
235
+ if (stat.isDirectory()) {
236
+ results.push(...findFiles(path, ext, depth - 1));
237
+ } else if (item.endsWith(ext)) {
238
+ results.push(path);
239
+ }
240
+ }
241
+ } catch (_) {}
242
+
243
+ return results;
244
+ }
245
+
246
+ /**
247
+ * Perform a single scan of watched directories.
248
+ */
249
+ export async function scanDirectories() {
250
+ const watchDirs = loadWatchedDirs();
251
+
252
+ for (const dir of watchDirs) {
253
+ if (!existsSync(dir)) continue;
254
+
255
+ // Scan for JSONL (Antigravity transcripts)
256
+ const jsonlFiles = findFiles(dir, 'transcript.jsonl', 3);
257
+ for (const file of jsonlFiles) {
258
+ await processJsonlFile(file);
259
+ }
260
+
261
+ // Scan for JSON (Roo Code / Cline task files)
262
+ const jsonFiles = findFiles(dir, '.json', 2);
263
+ for (const file of jsonFiles) {
264
+ // Avoid processing general configurations/settings files
265
+ if (file.includes('tasks')) {
266
+ await processJsonFile(file);
267
+ }
268
+ }
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Start the background log watcher daemon.
274
+ */
275
+ 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();
281
+
282
+ // Run initial scan
283
+ scanDirectories().catch(err => {
284
+ console.error(`[persyst-watcher] Initial scan failed: ${err.message}`);
285
+ });
286
+
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);
295
+ }
296
+
297
+ /**
298
+ * Stop the background log watcher daemon.
299
+ */
300
+ export function stopWatcher() {
301
+ if (intervalId) {
302
+ clearInterval(intervalId);
303
+ intervalId = null;
304
+ console.error('[persyst-watcher] Background log watcher daemon stopped.');
305
+ }
306
+ }