@mnemonik/scanner 1.0.0 → 2.0.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/daemon.ts CHANGED
@@ -4,34 +4,170 @@ import { join } from 'path';
4
4
  import { CodeScanner, type CodeChunk } from '@mnemonik/shared';
5
5
  import { MnemonikClient, type ScanPushFile } from './client.js';
6
6
  import { FileWatcher } from './watcher.js';
7
+ import { ProjectDiscovery, type DiscoveredProject } from './discovery.js';
7
8
 
8
9
  export interface DaemonConfig {
9
- projectId: string;
10
- projectRoot: string;
11
10
  serverUrl: string;
12
11
  apiKey: string;
12
+ roots: string[];
13
+ refreshIntervalMs?: number;
14
+ maxConcurrentScans?: number;
15
+ }
16
+
17
+ interface WatchedProject {
18
+ projectId: string;
19
+ path: string;
20
+ name?: string;
21
+ watcher: FileWatcher;
22
+ pendingRetries: Set<string>;
23
+ retryTimer: ReturnType<typeof setInterval> | null;
13
24
  }
14
25
 
15
26
  export class ScannerDaemon {
16
27
  private client: MnemonikClient;
17
28
  private scanner: CodeScanner;
18
- private watcher: FileWatcher | null = null;
19
- private pendingRetries: Set<string> = new Set();
20
- private retryTimer: ReturnType<typeof setInterval> | null = null;
29
+ private projects = new Map<string, WatchedProject>();
30
+ private refreshTimer: ReturnType<typeof setInterval> | null = null;
31
+ private discovery: ProjectDiscovery;
32
+ private refreshIntervalMs: number;
33
+ private maxConcurrentScans: number;
21
34
 
22
35
  constructor(private config: DaemonConfig) {
23
36
  this.client = new MnemonikClient(config.serverUrl, config.apiKey);
24
37
  this.scanner = new CodeScanner();
38
+ this.discovery = new ProjectDiscovery(config.roots);
39
+ this.refreshIntervalMs = config.refreshIntervalMs ?? 300_000; // 5 min
40
+ this.maxConcurrentScans = config.maxConcurrentScans ?? 5;
25
41
  }
26
42
 
27
43
  async start(): Promise<void> {
28
- console.log(`[scanner] Starting daemon for project: ${this.config.projectId}`);
44
+ console.log(`[scanner] Starting daemon`);
29
45
  console.log(`[scanner] Server: ${this.config.serverUrl}`);
30
- console.log(`[scanner] Root: ${this.config.projectRoot}`);
46
+ console.log(`[scanner] Roots: ${this.config.roots.join(', ')}`);
31
47
 
32
48
  await this.waitForServer();
33
- await this.initialScan();
34
- await this.startWatching();
49
+ await this.refreshProjects();
50
+
51
+ this.refreshTimer = setInterval(() => {
52
+ this.refreshProjects().catch((err) => {
53
+ console.warn('[scanner] Refresh failed:', (err as Error).message);
54
+ });
55
+ }, this.refreshIntervalMs);
56
+ this.refreshTimer.unref();
57
+
58
+ console.log('[scanner] Watching for changes.');
59
+ }
60
+
61
+ async stop(): Promise<void> {
62
+ if (this.refreshTimer) {
63
+ clearInterval(this.refreshTimer);
64
+ this.refreshTimer = null;
65
+ }
66
+
67
+ for (const project of this.projects.values()) {
68
+ project.watcher.stop();
69
+ if (project.retryTimer) clearInterval(project.retryTimer);
70
+ }
71
+ this.projects.clear();
72
+ console.log('[scanner] Daemon stopped');
73
+ }
74
+
75
+ getWatchedProjects(): Array<{ projectId: string; path: string; name?: string }> {
76
+ return Array.from(this.projects.values()).map((p) => ({
77
+ projectId: p.projectId,
78
+ path: p.path,
79
+ name: p.name,
80
+ }));
81
+ }
82
+
83
+ /**
84
+ * Discover projects from configured roots and reconcile with current watch list.
85
+ */
86
+ async refreshProjects(): Promise<void> {
87
+ const discovered = await this.discovery.discover();
88
+ const discoveredMap = new Map(discovered.map((d) => [d.projectId, d]));
89
+
90
+ // Remove projects no longer discovered
91
+ for (const [projectId, project] of this.projects) {
92
+ if (!discoveredMap.has(projectId)) {
93
+ console.log(`[scanner] Project removed: ${project.name ?? projectId} (${project.path})`);
94
+ project.watcher.stop();
95
+ if (project.retryTimer) clearInterval(project.retryTimer);
96
+ this.projects.delete(projectId);
97
+ }
98
+ }
99
+
100
+ // Add new projects or handle path changes
101
+ for (const discovered_project of discoveredMap.values()) {
102
+ const existing = this.projects.get(discovered_project.projectId);
103
+
104
+ if (!existing) {
105
+ // New project
106
+ await this.addProject(discovered_project);
107
+ } else if (existing.path !== discovered_project.path) {
108
+ // Path changed (folder renamed/moved)
109
+ console.log(
110
+ `[scanner] Project moved: ${existing.name ?? existing.projectId} ` +
111
+ `${existing.path} → ${discovered_project.path}`
112
+ );
113
+ existing.watcher.stop();
114
+ if (existing.retryTimer) clearInterval(existing.retryTimer);
115
+ this.projects.delete(discovered_project.projectId);
116
+ await this.addProject(discovered_project);
117
+ }
118
+ }
119
+
120
+ console.log(`[scanner] Watching ${this.projects.size} project(s)`);
121
+ }
122
+
123
+ private async addProject(discovered: DiscoveredProject): Promise<void> {
124
+ const label = discovered.projectName ?? discovered.projectId.slice(0, 8);
125
+ console.log(`[scanner] Adding project: ${label} (${discovered.path})`);
126
+
127
+ try {
128
+ await this.initialScan(discovered.projectId, discovered.path);
129
+ } catch (err) {
130
+ console.warn(
131
+ `[scanner] Initial scan failed for ${label}:`,
132
+ (err as Error).message
133
+ );
134
+ }
135
+
136
+ const watcher = new FileWatcher(
137
+ discovered.path,
138
+ (changedFiles) => this.handleChanges(discovered.projectId, discovered.path, changedFiles),
139
+ 500,
140
+ (err) => {
141
+ console.warn(
142
+ `[scanner] Root watcher error for ${label}: ${err.message}. Removing project.`
143
+ );
144
+ const project = this.projects.get(discovered.projectId);
145
+ if (project) {
146
+ project.watcher.stop();
147
+ if (project.retryTimer) clearInterval(project.retryTimer);
148
+ this.projects.delete(discovered.projectId);
149
+ }
150
+ }
151
+ );
152
+
153
+ try {
154
+ await watcher.start();
155
+ } catch (err) {
156
+ console.warn(
157
+ `[scanner] Failed to start watcher for ${label}:`,
158
+ (err as Error).message
159
+ );
160
+ return;
161
+ }
162
+
163
+ this.projects.set(discovered.projectId, {
164
+ projectId: discovered.projectId,
165
+ path: discovered.path,
166
+ name: discovered.projectName,
167
+ watcher,
168
+ pendingRetries: new Set(),
169
+ retryTimer: null,
170
+ });
35
171
  }
36
172
 
37
173
  private async waitForServer(): Promise<void> {
@@ -58,83 +194,46 @@ export class ScannerDaemon {
58
194
  throw new Error(`Server unreachable after ${maxRetries} attempts`);
59
195
  }
60
196
 
61
- getProjectRoot(): string {
62
- return this.config.projectRoot;
63
- }
64
-
65
- getProjectId(): string {
66
- return this.config.projectId;
67
- }
68
-
69
- async stop(): Promise<void> {
70
- this.watcher?.stop();
71
- if (this.retryTimer) clearInterval(this.retryTimer);
72
- console.log('[scanner] Daemon stopped');
73
- }
74
-
75
- private startRetryLoop(): void {
76
- if (this.retryTimer) return;
77
- console.log(`[scanner] ${this.pendingRetries.size} file(s) queued for retry`);
78
- this.retryTimer = setInterval(async () => {
79
- if (this.pendingRetries.size === 0) {
80
- if (this.retryTimer) clearInterval(this.retryTimer);
81
- this.retryTimer = null;
82
- return;
83
- }
84
- const files = [...this.pendingRetries];
85
- this.pendingRetries.clear();
86
- await this.handleChanges(files);
87
- }, 10_000);
88
- this.retryTimer.unref();
89
- }
90
-
91
- private async initialScan(): Promise<void> {
92
- console.log('[scanner] Starting initial scan...');
197
+ private async initialScan(projectId: string, projectRoot: string): Promise<void> {
93
198
  const startTime = Date.now();
94
199
 
95
- const chunks = await this.scanner.scanDirectory(this.config.projectRoot);
96
- console.log(`[scanner] Scanned ${chunks.length} chunks locally`);
97
-
98
- const serverHashes = await this.client.getStatus(this.config.projectId);
99
- console.log(`[scanner] Server knows ${serverHashes.size} files`);
200
+ const chunks = await this.scanner.scanDirectory(projectRoot);
201
+ const serverHashes = await this.client.getStatus(projectId);
100
202
 
101
- const files = await this.groupChunksByFile(chunks);
203
+ const files = await this.groupChunksByFile(chunks, projectRoot);
102
204
  const filesToPush = files.filter((f) => {
103
205
  const serverHash = serverHashes.get(f.path);
104
206
  return !serverHash || serverHash !== f.hash;
105
207
  });
106
208
 
107
- if (filesToPush.length === 0) {
108
- console.log('[scanner] All files up to date, nothing to push');
109
- } else {
110
- console.log(`[scanner] Pushing ${filesToPush.length} changed files...`);
111
- await this.client.pushFiles(this.config.projectId, filesToPush);
112
- console.log(`[scanner] Push complete`);
209
+ if (filesToPush.length > 0) {
210
+ console.log(`[scanner] Pushing ${filesToPush.length} changed files for ${projectId.slice(0, 8)}...`);
211
+ await this.client.pushFiles(projectId, filesToPush);
113
212
  }
114
213
 
115
214
  const duration = ((Date.now() - startTime) / 1000).toFixed(1);
116
- console.log(`[scanner] Initial scan complete in ${duration}s`);
117
- }
118
-
119
- private async startWatching(): Promise<void> {
120
- this.watcher = new FileWatcher(
121
- this.config.projectRoot,
122
- (changedFiles) => this.handleChanges(changedFiles),
123
- 500
215
+ console.log(
216
+ `[scanner] Scan complete for ${projectId.slice(0, 8)}: ` +
217
+ `${chunks.length} chunks, ${filesToPush.length} pushed (${duration}s)`
124
218
  );
125
- await this.watcher.start();
126
219
  }
127
220
 
128
- private async handleChanges(changedFiles: string[]): Promise<void> {
221
+ private async handleChanges(
222
+ projectId: string,
223
+ projectRoot: string,
224
+ changedFiles: string[]
225
+ ): Promise<void> {
226
+ const project = this.projects.get(projectId);
227
+ if (!project) return;
228
+
129
229
  try {
130
- const absPaths = changedFiles.map((rel) => join(this.config.projectRoot, rel));
131
- const chunks = await this.scanner.scanFiles(absPaths, this.config.projectRoot);
230
+ const absPaths = changedFiles.map((rel) => join(projectRoot, rel));
231
+ const chunks = await this.scanner.scanFiles(absPaths, projectRoot);
132
232
 
133
233
  if (chunks.length === 0) return;
134
234
 
135
- const files = await this.groupChunksByFile(chunks);
235
+ const files = await this.groupChunksByFile(chunks, projectRoot);
136
236
 
137
- // Push in batches, only retrying files from failed batches
138
237
  const batchSize = 25;
139
238
  const succeededPaths = new Set<string>();
140
239
  let hadFailure = false;
@@ -142,39 +241,55 @@ export class ScannerDaemon {
142
241
  for (let i = 0; i < files.length; i += batchSize) {
143
242
  const batch = files.slice(i, i + batchSize);
144
243
  try {
145
- await this.client.pushFiles(this.config.projectId, batch);
244
+ await this.client.pushFiles(projectId, batch);
146
245
  for (const f of batch) succeededPaths.add(f.path);
147
246
  } catch {
148
247
  hadFailure = true;
149
- for (const f of batch) this.pendingRetries.add(f.path);
248
+ for (const f of batch) project.pendingRetries.add(f.path);
150
249
  }
151
250
  }
152
251
 
153
252
  if (succeededPaths.size > 0) {
154
253
  console.log(
155
- `[scanner] Pushed ${succeededPaths.size} changed file(s): ${[...succeededPaths].join(', ')}`
254
+ `[scanner] [${projectId.slice(0, 8)}] Pushed ${succeededPaths.size} file(s): ${[...succeededPaths].join(', ')}`
156
255
  );
157
256
  }
158
257
  if (hadFailure) {
159
- console.warn(`[scanner] ${this.pendingRetries.size} file(s) failed, queued for retry`);
160
- this.startRetryLoop();
258
+ console.warn(
259
+ `[scanner] [${projectId.slice(0, 8)}] ${project.pendingRetries.size} file(s) failed, queued for retry`
260
+ );
261
+ this.startRetryLoop(project);
161
262
  }
162
263
  } catch (err) {
163
- console.error('[scanner] Error handling changes:', err);
264
+ console.error(`[scanner] [${projectId.slice(0, 8)}] Error handling changes:`, err);
164
265
  }
165
266
  }
166
267
 
167
- private async groupChunksByFile(chunks: CodeChunk[]): Promise<ScanPushFile[]> {
268
+ private startRetryLoop(project: WatchedProject): void {
269
+ if (project.retryTimer) return;
270
+ project.retryTimer = setInterval(async () => {
271
+ if (project.pendingRetries.size === 0) {
272
+ if (project.retryTimer) clearInterval(project.retryTimer);
273
+ project.retryTimer = null;
274
+ return;
275
+ }
276
+ const files = [...project.pendingRetries];
277
+ project.pendingRetries.clear();
278
+ await this.handleChanges(project.projectId, project.path, files);
279
+ }, 10_000);
280
+ project.retryTimer.unref();
281
+ }
282
+
283
+ private async groupChunksByFile(
284
+ chunks: CodeChunk[],
285
+ projectRoot: string
286
+ ): Promise<ScanPushFile[]> {
168
287
  const fileMap = new Map<string, ScanPushFile>();
169
288
 
170
289
  for (const chunk of chunks) {
171
290
  const key = chunk.filePath;
172
291
  if (!fileMap.has(key)) {
173
- fileMap.set(key, {
174
- path: key,
175
- hash: '',
176
- chunks: [],
177
- });
292
+ fileMap.set(key, { path: key, hash: '', chunks: [] });
178
293
  }
179
294
  const file = fileMap.get(key)!;
180
295
  file.chunks.push({
@@ -190,7 +305,7 @@ export class ScannerDaemon {
190
305
 
191
306
  for (const file of fileMap.values()) {
192
307
  try {
193
- const absPath = join(this.config.projectRoot, file.path);
308
+ const absPath = join(projectRoot, file.path);
194
309
  const raw = await readFile(absPath, 'utf-8');
195
310
  file.hash = createHash('sha256').update(raw).digest('hex');
196
311
  } catch (err) {
@@ -0,0 +1,125 @@
1
+ import { readFile, readdir, stat } from 'fs/promises';
2
+ import { join, resolve } from 'path';
3
+
4
+ /** Directories to skip during discovery walk */
5
+ const SKIP_DIRS = new Set([
6
+ 'node_modules',
7
+ '.git',
8
+ 'dist',
9
+ 'build',
10
+ '.next',
11
+ '.nuxt',
12
+ '.output',
13
+ '__pycache__',
14
+ '.venv',
15
+ 'venv',
16
+ '.tox',
17
+ 'target',
18
+ '.cache',
19
+ 'coverage',
20
+ '.turbo',
21
+ '.vercel',
22
+ '.svelte-kit',
23
+ ]);
24
+
25
+ export interface DiscoveredProject {
26
+ projectId: string;
27
+ path: string;
28
+ projectName?: string;
29
+ }
30
+
31
+ export class ProjectDiscovery {
32
+ private maxDepth: number;
33
+ private timeoutMs: number;
34
+
35
+ constructor(
36
+ private roots: string[],
37
+ options?: { maxDepth?: number; timeoutMs?: number }
38
+ ) {
39
+ this.maxDepth = options?.maxDepth ?? 3;
40
+ this.timeoutMs = options?.timeoutMs ?? 30_000;
41
+ }
42
+
43
+ /**
44
+ * Discover all projects with .mnemonik.json files under the configured roots.
45
+ * Deduplicates by projectId (same project found at multiple paths = first wins).
46
+ */
47
+ async discover(): Promise<DiscoveredProject[]> {
48
+ const seen = new Map<string, DiscoveredProject>();
49
+
50
+ for (const root of this.roots) {
51
+ const absRoot = resolve(root.replace(/^~/, process.env.HOME || ''));
52
+ try {
53
+ await this.walkWithTimeout(absRoot, 0, seen);
54
+ } catch (err) {
55
+ if (err instanceof DiscoveryTimeoutError) {
56
+ console.warn(`[scanner] Discovery timeout for root: ${absRoot} (>${this.timeoutMs}ms)`);
57
+ } else {
58
+ console.warn(`[scanner] Error scanning root ${absRoot}:`, (err as Error).message);
59
+ }
60
+ }
61
+ }
62
+
63
+ return Array.from(seen.values());
64
+ }
65
+
66
+ private async walkWithTimeout(
67
+ dir: string,
68
+ depth: number,
69
+ seen: Map<string, DiscoveredProject>
70
+ ): Promise<void> {
71
+ const deadline = Date.now() + this.timeoutMs;
72
+ await this.walk(dir, depth, seen, deadline);
73
+ }
74
+
75
+ private async walk(
76
+ dir: string,
77
+ depth: number,
78
+ seen: Map<string, DiscoveredProject>,
79
+ deadline: number
80
+ ): Promise<void> {
81
+ if (depth > this.maxDepth) return;
82
+ if (Date.now() > deadline) throw new DiscoveryTimeoutError();
83
+
84
+ // Check for .mnemonik.json in this directory
85
+ const configPath = join(dir, '.mnemonik.json');
86
+ try {
87
+ const raw = await readFile(configPath, 'utf-8');
88
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
89
+ if (typeof parsed.projectId === 'string' && parsed.projectId.length > 0) {
90
+ if (!seen.has(parsed.projectId)) {
91
+ seen.set(parsed.projectId, {
92
+ projectId: parsed.projectId,
93
+ path: dir,
94
+ projectName:
95
+ typeof parsed.projectName === 'string' ? parsed.projectName : undefined,
96
+ });
97
+ }
98
+ }
99
+ // Don't recurse into subdirectories of a project — the project owns this tree
100
+ return;
101
+ } catch {
102
+ // No .mnemonik.json here, continue walking
103
+ }
104
+
105
+ // Recurse into subdirectories
106
+ try {
107
+ const entries = await readdir(dir, { withFileTypes: true });
108
+ for (const entry of entries) {
109
+ if (!entry.isDirectory()) continue;
110
+ if (SKIP_DIRS.has(entry.name)) continue;
111
+ if (entry.name.startsWith('.') && entry.name !== '.mnemonik') continue;
112
+ await this.walk(join(dir, entry.name), depth + 1, seen, deadline);
113
+ }
114
+ } catch {
115
+ // Permission denied or inaccessible directory
116
+ }
117
+ }
118
+ }
119
+
120
+ class DiscoveryTimeoutError extends Error {
121
+ constructor() {
122
+ super('Discovery walk timed out');
123
+ this.name = 'DiscoveryTimeoutError';
124
+ }
125
+ }