@telepat/snoopy 0.1.13 → 0.1.15

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.
Files changed (36) hide show
  1. package/README.md +70 -215
  2. package/README.zh-CN.md +137 -0
  3. package/dist/src/agent/install.d.ts +18 -0
  4. package/dist/src/agent/install.js +488 -0
  5. package/dist/src/cli/commands/feedback.d.ts +18 -0
  6. package/dist/src/cli/commands/feedback.js +276 -0
  7. package/dist/src/cli/commands/prompt.d.ts +6 -0
  8. package/dist/src/cli/commands/prompt.js +92 -0
  9. package/dist/src/cli/commands/promptEditor.d.ts +1 -0
  10. package/dist/src/cli/commands/promptEditor.js +17 -0
  11. package/dist/src/cli/flows/jobAddFlow.js +1 -1
  12. package/dist/src/cli/index.js +86 -1
  13. package/dist/src/mcp/helpers.d.ts +46 -0
  14. package/dist/src/mcp/helpers.js +506 -0
  15. package/dist/src/mcp/server.d.ts +1 -0
  16. package/dist/src/mcp/server.js +299 -0
  17. package/dist/src/mcp/tools.d.ts +90 -0
  18. package/dist/src/mcp/tools.js +106 -0
  19. package/dist/src/services/db/migrations/002_feedback_fields.d.ts +7 -0
  20. package/dist/src/services/db/migrations/002_feedback_fields.js +22 -0
  21. package/dist/src/services/db/migrations/index.js +2 -1
  22. package/dist/src/services/db/repositories/jobsRepo.d.ts +2 -0
  23. package/dist/src/services/db/repositories/jobsRepo.js +15 -0
  24. package/dist/src/services/db/repositories/scanItemsRepo.d.ts +17 -0
  25. package/dist/src/services/db/repositories/scanItemsRepo.js +197 -2
  26. package/dist/src/services/feedback/consolidationService.d.ts +28 -0
  27. package/dist/src/services/feedback/consolidationService.js +124 -0
  28. package/dist/src/services/openrouter/client.d.ts +23 -0
  29. package/dist/src/services/openrouter/client.js +67 -0
  30. package/dist/src/types/settings.d.ts +1 -1
  31. package/dist/src/types/settings.js +1 -1
  32. package/dist/src/ui/components/MultilinePrompt.d.ts +10 -0
  33. package/dist/src/ui/components/MultilinePrompt.js +87 -0
  34. package/dist/src/ui/components/multilinePromptModel.d.ts +25 -0
  35. package/dist/src/ui/components/multilinePromptModel.js +76 -0
  36. package/package.json +4 -1
@@ -0,0 +1,506 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { getDb } from '../services/db/sqlite.js';
5
+ import { JobsRepository } from '../services/db/repositories/jobsRepo.js';
6
+ import { RunsRepository } from '../services/db/repositories/runsRepo.js';
7
+ import { ScanItemsRepository } from '../services/db/repositories/scanItemsRepo.js';
8
+ import { SettingsRepository } from '../services/db/repositories/settingsRepo.js';
9
+ import { consolidateFeedback } from '../services/feedback/consolidationService.js';
10
+ import { AnalyticsService } from '../services/analytics/analyticsService.js';
11
+ import { extractErrorEntries, readRunLog } from '../services/logging/logReader.js';
12
+ import { getOpenRouterApiKey, isKeytarAvailable } from '../services/security/secretStore.js';
13
+ import { getStartupStatus } from '../services/startup/index.js';
14
+ import { isDaemonRunning, ensureDaemonRunning, requestDaemonReload } from '../services/daemonControl.js';
15
+ import { ensureAppDirs } from '../utils/paths.js';
16
+ export function getSnoopyVersion() {
17
+ const envVersion = process.env.npm_package_version;
18
+ if (envVersion) {
19
+ return envVersion;
20
+ }
21
+ const argvDir = process.argv[1] ? path.dirname(process.argv[1]) : process.cwd();
22
+ const candidates = [
23
+ path.resolve(process.cwd(), 'package.json'),
24
+ path.resolve(process.cwd(), 'snoopy/package.json'),
25
+ path.resolve(argvDir, '../package.json'),
26
+ path.resolve(argvDir, '../../package.json'),
27
+ ];
28
+ for (const candidate of candidates) {
29
+ try {
30
+ const raw = fs.readFileSync(candidate, 'utf8');
31
+ const pkg = JSON.parse(raw);
32
+ if (pkg.name === '@telepat/snoopy') {
33
+ return pkg.version ?? '0.0.0';
34
+ }
35
+ }
36
+ catch { /* try next depth */ }
37
+ }
38
+ return '0.0.0';
39
+ }
40
+ function formatToolError(error) {
41
+ const message = error instanceof Error ? error.message : String(error ?? 'Unknown error');
42
+ return {
43
+ content: [{ type: 'text', text: message }],
44
+ isError: true,
45
+ };
46
+ }
47
+ function formatToolResult(data) {
48
+ return {
49
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
50
+ structuredContent: data,
51
+ };
52
+ }
53
+ export { formatToolError, formatToolResult };
54
+ export async function buildDoctorReport() {
55
+ const paths = ensureAppDirs();
56
+ const db = getDb();
57
+ let dbOk = false;
58
+ let dbDetails = `DB file: ${paths.dbPath}`;
59
+ try {
60
+ db.prepare('SELECT 1').get();
61
+ dbOk = true;
62
+ dbDetails = `DB reachable at ${paths.dbPath}`;
63
+ }
64
+ catch (error) {
65
+ dbDetails = `DB error: ${String(error)}`;
66
+ }
67
+ const jobsRepo = new JobsRepository();
68
+ const runsRepo = new RunsRepository();
69
+ const jobs = jobsRepo.list();
70
+ const enabledJobs = jobs.filter((j) => j.enabled).length;
71
+ const apiKey = await getOpenRouterApiKey();
72
+ const keytarAvailable = await isKeytarAvailable();
73
+ const startup = getStartupStatus();
74
+ const daemon = isDaemonRunning();
75
+ const recentProblemRuns = runsRepo
76
+ .latestWithJobNames(20)
77
+ .filter((run) => {
78
+ const timestamp = Date.parse(run.createdAt);
79
+ return !Number.isNaN(timestamp) && timestamp >= Date.now() - 24 * 60 * 60 * 1000;
80
+ })
81
+ .map((run) => {
82
+ const logContent = readRunLog(run.logFilePath);
83
+ const errorEntries = extractErrorEntries(logContent ?? '');
84
+ return { run, errorEntries };
85
+ })
86
+ .filter(({ run, errorEntries }) => run.status === 'failed' || errorEntries.length > 0);
87
+ return {
88
+ platform: process.platform,
89
+ nodeVersion: process.version,
90
+ database: { ok: dbOk, details: dbDetails },
91
+ openRouterApiKey: { configured: Boolean(apiKey), keytarAvailable },
92
+ jobs: { total: jobs.length, enabled: enabledJobs },
93
+ daemon: { running: daemon.running, pid: daemon.pid },
94
+ startup: { enabled: startup.enabled, method: startup.method, detail: startup.detail },
95
+ recentErrors: recentProblemRuns.map(({ run, errorEntries }) => ({
96
+ runId: run.id,
97
+ jobName: run.jobName ?? run.jobId,
98
+ status: run.status,
99
+ message: run.message,
100
+ errorCount: errorEntries.length,
101
+ latestError: errorEntries.length > 0 ? errorEntries[errorEntries.length - 1]?.split('\n')[0] : null,
102
+ })),
103
+ };
104
+ }
105
+ export function buildDaemonStatusReport() {
106
+ const status = isDaemonRunning();
107
+ return {
108
+ running: status.running,
109
+ pid: status.pid,
110
+ };
111
+ }
112
+ export function startDaemonReport() {
113
+ const result = ensureDaemonRunning();
114
+ return {
115
+ started: result.started,
116
+ pid: result.pid,
117
+ };
118
+ }
119
+ export function stopDaemonReport() {
120
+ const status = isDaemonRunning();
121
+ if (!status.running || !status.pid) {
122
+ return { stopped: false, message: 'Daemon is not running' };
123
+ }
124
+ try {
125
+ process.kill(status.pid, 'SIGTERM');
126
+ return { stopped: true, pid: status.pid };
127
+ }
128
+ catch (error) {
129
+ return { stopped: false, pid: status.pid, error: String(error) };
130
+ }
131
+ }
132
+ export function reloadDaemonReport() {
133
+ const result = requestDaemonReload();
134
+ return {
135
+ reloaded: result.reloaded,
136
+ pid: result.pid,
137
+ };
138
+ }
139
+ export function listJobsReport() {
140
+ const jobsRepo = new JobsRepository();
141
+ const jobs = jobsRepo.list();
142
+ return {
143
+ count: jobs.length,
144
+ jobs: jobs.map((j) => ({
145
+ id: j.id,
146
+ slug: j.slug,
147
+ name: j.name,
148
+ enabled: j.enabled,
149
+ subreddits: j.subreddits,
150
+ scheduleCron: j.scheduleCron,
151
+ monitorComments: j.monitorComments,
152
+ })),
153
+ };
154
+ }
155
+ export function listJobRunsReport(jobRef, limit) {
156
+ const runsRepo = new RunsRepository();
157
+ const boundedLimit = limit ?? 20;
158
+ if (jobRef) {
159
+ const jobsRepo = new JobsRepository();
160
+ const job = jobsRepo.getByRef(jobRef);
161
+ if (!job) {
162
+ throw new Error(`Job not found: ${jobRef}`);
163
+ }
164
+ const runs = runsRepo.listByJob(job.id, boundedLimit);
165
+ return {
166
+ job: { id: job.id, slug: job.slug, name: job.name },
167
+ count: runs.length,
168
+ runs: runs.map((r) => ({
169
+ id: r.id,
170
+ status: r.status,
171
+ message: r.message,
172
+ startedAt: r.startedAt,
173
+ finishedAt: r.finishedAt,
174
+ itemsDiscovered: r.itemsDiscovered,
175
+ itemsNew: r.itemsNew,
176
+ itemsQualified: r.itemsQualified,
177
+ promptTokens: r.promptTokens,
178
+ completionTokens: r.completionTokens,
179
+ estimatedCostUsd: r.estimatedCostUsd,
180
+ })),
181
+ };
182
+ }
183
+ const runs = runsRepo.latestWithJobNames(boundedLimit);
184
+ return {
185
+ count: runs.length,
186
+ runs: runs.map((r) => ({
187
+ id: r.id,
188
+ jobId: r.jobId,
189
+ jobName: r.jobName,
190
+ status: r.status,
191
+ message: r.message,
192
+ createdAt: r.createdAt,
193
+ itemsDiscovered: r.itemsDiscovered,
194
+ itemsNew: r.itemsNew,
195
+ itemsQualified: r.itemsQualified,
196
+ })),
197
+ };
198
+ }
199
+ export function addJobReport(input) {
200
+ const jobsRepo = new JobsRepository();
201
+ const job = jobsRepo.create({
202
+ name: input.name,
203
+ description: input.description ?? '',
204
+ subreddits: input.subreddits,
205
+ qualificationPrompt: input.qualificationPrompt,
206
+ scheduleCron: input.scheduleCron,
207
+ enabled: input.enabled,
208
+ monitorComments: input.monitorComments,
209
+ });
210
+ return {
211
+ id: job.id,
212
+ slug: job.slug,
213
+ name: job.name,
214
+ enabled: job.enabled,
215
+ subreddits: job.subreddits,
216
+ scheduleCron: job.scheduleCron,
217
+ };
218
+ }
219
+ export function deleteJobReport(jobRef) {
220
+ const jobsRepo = new JobsRepository();
221
+ const job = jobsRepo.removeByRef(jobRef);
222
+ if (!job) {
223
+ throw new Error(`Job not found: ${jobRef}`);
224
+ }
225
+ return { deleted: true, id: job.id, slug: job.slug, name: job.name };
226
+ }
227
+ export function enableJobReport(jobRef) {
228
+ const jobsRepo = new JobsRepository();
229
+ const job = jobsRepo.setEnabledByRef(jobRef, true);
230
+ if (!job) {
231
+ throw new Error(`Job not found: ${jobRef}`);
232
+ }
233
+ return { id: job.id, slug: job.slug, name: job.name, enabled: job.enabled };
234
+ }
235
+ export function disableJobReport(jobRef) {
236
+ const jobsRepo = new JobsRepository();
237
+ const job = jobsRepo.setEnabledByRef(jobRef, false);
238
+ if (!job) {
239
+ throw new Error(`Job not found: ${jobRef}`);
240
+ }
241
+ return { id: job.id, slug: job.slug, name: job.name, enabled: job.enabled };
242
+ }
243
+ export function runJobReport(jobRef, limit) {
244
+ const jobsRepo = new JobsRepository();
245
+ const job = jobsRepo.getByRef(jobRef);
246
+ if (!job) {
247
+ throw new Error(`Job not found: ${jobRef}`);
248
+ }
249
+ const args = ['job', 'run', jobRef];
250
+ if (limit) {
251
+ args.push('--limit', String(limit));
252
+ }
253
+ const result = spawnSync(process.execPath, [process.argv[1], ...args], {
254
+ encoding: 'utf8',
255
+ timeout: 120_000,
256
+ });
257
+ return {
258
+ job: { id: job.id, slug: job.slug, name: job.name },
259
+ exitCode: result.status,
260
+ stdout: result.stdout?.slice(0, 4000) ?? '',
261
+ stderr: result.stderr?.slice(0, 2000) ?? '',
262
+ };
263
+ }
264
+ export function analyticsReport(jobRef, days) {
265
+ const analyticsService = new AnalyticsService();
266
+ const boundedDays = days ?? 30;
267
+ if (jobRef) {
268
+ const jobsRepo = new JobsRepository();
269
+ const job = jobsRepo.getByRef(jobRef);
270
+ if (!job) {
271
+ throw new Error(`Job not found: ${jobRef}`);
272
+ }
273
+ return analyticsService.getJobAnalytics(job.id, { days: boundedDays });
274
+ }
275
+ return analyticsService.getGlobalAnalytics({ days: boundedDays });
276
+ }
277
+ export function exportReport(jobRef, format, lastRun, limit) {
278
+ const jobsRepo = new JobsRepository();
279
+ const scanItemsRepo = new ScanItemsRepository();
280
+ const runsRepo = new RunsRepository();
281
+ const boundedLimit = limit ?? 100;
282
+ const jobs = jobRef
283
+ ? (() => {
284
+ const job = jobsRepo.getByRef(jobRef);
285
+ if (!job)
286
+ throw new Error(`Job not found: ${jobRef}`);
287
+ return [job];
288
+ })()
289
+ : jobsRepo.list();
290
+ const results = [];
291
+ for (const job of jobs) {
292
+ const latestRunId = lastRun ? runsRepo.listByJob(job.id, 1)[0]?.id ?? null : null;
293
+ if (lastRun && !latestRunId)
294
+ continue;
295
+ const items = latestRunId
296
+ ? scanItemsRepo.listQualifiedByJobRun(job.id, latestRunId, boundedLimit)
297
+ : scanItemsRepo.listQualifiedByJob(job.id, boundedLimit);
298
+ results.push({
299
+ jobId: job.id,
300
+ jobSlug: job.slug,
301
+ jobName: job.name,
302
+ rowCount: items.length,
303
+ items,
304
+ });
305
+ }
306
+ return { format: format ?? 'json', jobs: results, totalRows: results.reduce((s, r) => s + r.rowCount, 0) };
307
+ }
308
+ export function consumeReport(jobRef, limit, dryRun) {
309
+ const jobsRepo = new JobsRepository();
310
+ const scanItemsRepo = new ScanItemsRepository();
311
+ let jobId;
312
+ if (jobRef) {
313
+ const job = jobsRepo.getByRef(jobRef);
314
+ if (!job)
315
+ throw new Error(`Job not found: ${jobRef}`);
316
+ jobId = job.id;
317
+ }
318
+ const rows = scanItemsRepo.listUnconsumedQualified(jobId, limit);
319
+ if (dryRun) {
320
+ return { dryRun: true, count: rows.length, items: rows };
321
+ }
322
+ const consumedCount = scanItemsRepo.markConsumed(rows.map((r) => r.id));
323
+ return { consumed: consumedCount, items: rows };
324
+ }
325
+ export function feedbackReviewReport(jobRef, limit) {
326
+ const jobsRepo = new JobsRepository();
327
+ const scanItemsRepo = new ScanItemsRepository();
328
+ let jobId;
329
+ if (jobRef) {
330
+ const job = jobsRepo.getByRef(jobRef);
331
+ if (!job) {
332
+ throw new Error(`Job not found: ${jobRef}`);
333
+ }
334
+ jobId = job.id;
335
+ }
336
+ const boundedLimit = limit ?? 10;
337
+ const items = scanItemsRepo.listUnvalidatedQualified(jobId, boundedLimit);
338
+ return {
339
+ count: items.length,
340
+ limit: boundedLimit,
341
+ items,
342
+ workflow: {
343
+ nextStep: 'collect-user-feedback-and-call-snoopy_feedback_submit',
344
+ then: 'call-snoopy_feedback_consolidate'
345
+ }
346
+ };
347
+ }
348
+ export function feedbackSubmitReport(resultId, isValid, reason) {
349
+ const scanItemsRepo = new ScanItemsRepository();
350
+ const row = scanItemsRepo.getQualifiedById(resultId);
351
+ if (!row) {
352
+ throw new Error(`Qualified result not found: ${resultId}`);
353
+ }
354
+ const normalizedReason = reason?.trim() ?? '';
355
+ if (!isValid && normalizedReason.length === 0) {
356
+ throw new Error('reason is required when isValid=false');
357
+ }
358
+ const updated = scanItemsRepo.submitFeedback(resultId, isValid, isValid ? null : normalizedReason);
359
+ if (!updated) {
360
+ throw new Error(`Failed to save feedback for result: ${resultId}`);
361
+ }
362
+ const pendingFeedbackConsolidationCount = scanItemsRepo.countPendingFeedbackConsolidation(row.jobId);
363
+ return {
364
+ resultId,
365
+ saved: true,
366
+ feedback: {
367
+ validated: true,
368
+ isValid,
369
+ isValidReason: isValid ? null : normalizedReason,
370
+ feedbackConsolidated: false,
371
+ },
372
+ pendingFeedbackConsolidationCount,
373
+ requiresConsolidation: pendingFeedbackConsolidationCount > 0,
374
+ recommendedNextCommand: 'snoopy feedback consolidate',
375
+ };
376
+ }
377
+ export async function feedbackConsolidateReport(jobRef, limit) {
378
+ const result = await consolidateFeedback({ jobRef, limit });
379
+ return {
380
+ ...result,
381
+ recommendedNextAction: result.requiresConsolidation ? 'run snoopy feedback consolidate again' : 'none',
382
+ };
383
+ }
384
+ export function errorsReport(jobRef, hours) {
385
+ const jobsRepo = new JobsRepository();
386
+ const runsRepo = new RunsRepository();
387
+ const boundedHours = hours ?? 24;
388
+ const job = jobsRepo.getByRef(jobRef);
389
+ if (!job) {
390
+ throw new Error(`Job not found: ${jobRef}`);
391
+ }
392
+ const cutoff = Date.now() - boundedHours * 60 * 60 * 1000;
393
+ const recentRuns = runsRepo.listByJob(job.id, 100).filter((run) => {
394
+ const ts = Date.parse(run.createdAt);
395
+ return !Number.isNaN(ts) && ts >= cutoff;
396
+ });
397
+ const errorRuns = recentRuns
398
+ .map((run) => {
399
+ const logContent = readRunLog(run.logFilePath);
400
+ const errorEntries = extractErrorEntries(logContent ?? '');
401
+ return { run, errorEntries, hasErrors: run.status === 'failed' || errorEntries.length > 0 };
402
+ })
403
+ .filter((entry) => entry.hasErrors);
404
+ return {
405
+ job: { id: job.id, slug: job.slug, name: job.name },
406
+ hours: boundedHours,
407
+ errorCount: errorRuns.length,
408
+ errors: errorRuns.map(({ run, errorEntries }) => ({
409
+ runId: run.id,
410
+ status: run.status,
411
+ message: run.message,
412
+ createdAt: run.createdAt,
413
+ errorEntries: errorEntries.map((e) => e.split('\n')[0]),
414
+ })),
415
+ };
416
+ }
417
+ export function logsReport(runId) {
418
+ const runsRepo = new RunsRepository();
419
+ const run = runsRepo.getById(runId);
420
+ if (!run) {
421
+ throw new Error(`Run not found: ${runId}`);
422
+ }
423
+ const logContent = readRunLog(run.logFilePath);
424
+ return {
425
+ runId: run.id,
426
+ jobId: run.jobId,
427
+ jobName: run.jobName,
428
+ status: run.status,
429
+ logPath: run.logFilePath,
430
+ logLength: logContent?.length ?? 0,
431
+ log: logContent?.slice(0, 10_000) ?? null,
432
+ };
433
+ }
434
+ export async function settingsGetReport() {
435
+ const settingsRepo = new SettingsRepository();
436
+ const appSettings = settingsRepo.getAppSettings();
437
+ const apiKey = await getOpenRouterApiKey();
438
+ const redditState = await settingsRepo.getRedditCredentialState();
439
+ return {
440
+ model: appSettings.model,
441
+ temperature: appSettings.modelSettings.temperature,
442
+ maxTokens: appSettings.modelSettings.maxTokens,
443
+ topP: appSettings.modelSettings.topP,
444
+ cronIntervalMinutes: appSettings.cronIntervalMinutes,
445
+ jobTimeoutMs: appSettings.jobTimeoutMs,
446
+ notificationsEnabled: appSettings.notificationsEnabled,
447
+ openRouterApiKeyConfigured: Boolean(apiKey),
448
+ reddit: {
449
+ appName: redditState.appName,
450
+ clientId: redditState.clientId,
451
+ hasClientSecret: redditState.hasClientSecret,
452
+ },
453
+ };
454
+ }
455
+ export function settingsSetReport(key, value) {
456
+ const settingsRepo = new SettingsRepository();
457
+ const appSettings = settingsRepo.getAppSettings();
458
+ switch (key) {
459
+ case 'model':
460
+ appSettings.model = value;
461
+ break;
462
+ case 'temperature': {
463
+ const parsed = Number(value);
464
+ if (Number.isNaN(parsed) || parsed < 0 || parsed > 2)
465
+ throw new Error('temperature must be 0.0-2.0');
466
+ appSettings.modelSettings.temperature = parsed;
467
+ break;
468
+ }
469
+ case 'maxTokens': {
470
+ const parsed = Number(value);
471
+ if (!Number.isInteger(parsed) || parsed < 1)
472
+ throw new Error('maxTokens must be a positive integer');
473
+ appSettings.modelSettings.maxTokens = parsed;
474
+ break;
475
+ }
476
+ case 'topP': {
477
+ const parsed = Number(value);
478
+ if (Number.isNaN(parsed) || parsed < 0 || parsed > 1)
479
+ throw new Error('topP must be 0.0-1.0');
480
+ appSettings.modelSettings.topP = parsed;
481
+ break;
482
+ }
483
+ case 'cronIntervalMinutes': {
484
+ const parsed = Number(value);
485
+ if (!Number.isInteger(parsed) || parsed < 1)
486
+ throw new Error('cronIntervalMinutes must be a positive integer');
487
+ appSettings.cronIntervalMinutes = parsed;
488
+ break;
489
+ }
490
+ case 'jobTimeoutMs': {
491
+ const parsed = Number(value);
492
+ if (!Number.isInteger(parsed) || parsed < 0)
493
+ throw new Error('jobTimeoutMs must be a non-negative integer');
494
+ appSettings.jobTimeoutMs = parsed;
495
+ break;
496
+ }
497
+ case 'notificationsEnabled':
498
+ appSettings.notificationsEnabled = value === 'true';
499
+ break;
500
+ default:
501
+ throw new Error(`Unknown setting: ${key}`);
502
+ }
503
+ settingsRepo.setAppSettings(appSettings);
504
+ return { updated: key, value, settings: appSettings };
505
+ }
506
+ //# sourceMappingURL=helpers.js.map
@@ -0,0 +1 @@
1
+ export declare function startSnoopyMcpServer(): Promise<void>;