@getlore/cli 0.6.0 → 0.7.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.
@@ -8,6 +8,7 @@
8
8
  * 4. Synthesizes findings into a comprehensive research package
9
9
  */
10
10
  import type { ResearchPackage } from '../../core/types.js';
11
+ import type { ProgressCallback } from './research.js';
11
12
  interface ResearchAgentArgs {
12
13
  task: string;
13
14
  project?: string;
@@ -17,5 +18,5 @@ interface ResearchAgentArgs {
17
18
  /**
18
19
  * Run the agentic research
19
20
  */
20
- export declare function runResearchAgent(dbPath: string, dataDir: string, args: ResearchAgentArgs): Promise<ResearchPackage>;
21
+ export declare function runResearchAgent(dbPath: string, dataDir: string, args: ResearchAgentArgs, onProgress?: ProgressCallback): Promise<ResearchPackage>;
21
22
  export {};
@@ -232,7 +232,7 @@ Now begin your research. Use the tools iteratively until you have comprehensive
232
232
  /**
233
233
  * Run the agentic research
234
234
  */
235
- export async function runResearchAgent(dbPath, dataDir, args) {
235
+ export async function runResearchAgent(dbPath, dataDir, args, onProgress) {
236
236
  const { task, project, include_sources = true } = args;
237
237
  // Load archived projects to filter (extract just the project names)
238
238
  const archivedProjectsData = await loadArchivedProjects(dataDir);
@@ -245,6 +245,8 @@ export async function runResearchAgent(dbPath, dataDir, args) {
245
245
  let lastAssistantMessage = '';
246
246
  try {
247
247
  // Run the agent
248
+ let turnCount = 0;
249
+ await onProgress?.(5, undefined, 'Starting research agent...');
248
250
  for await (const message of query({
249
251
  prompt: `Research task: ${task}${project ? ` (project: ${project})` : ''}`,
250
252
  options: {
@@ -261,8 +263,9 @@ export async function runResearchAgent(dbPath, dataDir, args) {
261
263
  permissionMode: 'acceptEdits', // Auto-approve tool calls
262
264
  },
263
265
  })) {
264
- // Capture assistant messages (intermediate)
266
+ // Capture assistant messages and extract tool call details
265
267
  if (message.type === 'assistant') {
268
+ turnCount++;
266
269
  const msg = message;
267
270
  if (msg.message?.content) {
268
271
  const content = msg.message.content;
@@ -270,9 +273,30 @@ export async function runResearchAgent(dbPath, dataDir, args) {
270
273
  lastAssistantMessage = content;
271
274
  }
272
275
  else if (Array.isArray(content)) {
273
- const textBlocks = content.filter((b) => b.type === 'text');
274
- if (textBlocks.length > 0) {
275
- lastAssistantMessage = textBlocks.map((b) => b.text).join('\n');
276
+ // Extract tool_use blocks to report what the agent is doing
277
+ for (const block of content) {
278
+ if (block.type === 'tool_use') {
279
+ const input = block.input;
280
+ const toolShort = block.name.replace('mcp__lore-tools__', '');
281
+ if (toolShort === 'search' && input.query) {
282
+ await onProgress?.(0, undefined, `Searching: "${input.query}"`);
283
+ }
284
+ else if (toolShort === 'get_source' && input.source_id) {
285
+ await onProgress?.(0, undefined, `Reading source: ${input.source_id}`);
286
+ }
287
+ else if (toolShort === 'list_sources') {
288
+ const filter = input.project ? ` (project: ${input.project})` : '';
289
+ await onProgress?.(0, undefined, `Listing sources${filter}`);
290
+ }
291
+ }
292
+ else if (block.type === 'text' && block.text) {
293
+ lastAssistantMessage = block.text;
294
+ // Send a brief snippet of agent reasoning
295
+ const snippet = block.text.substring(0, 120).replace(/\n/g, ' ');
296
+ if (snippet.length > 10) {
297
+ await onProgress?.(0, undefined, `Agent thinking: ${snippet}...`);
298
+ }
299
+ }
276
300
  }
277
301
  }
278
302
  }
@@ -282,16 +306,22 @@ export async function runResearchAgent(dbPath, dataDir, args) {
282
306
  const msg = message;
283
307
  if (msg.subtype === 'success' && msg.result) {
284
308
  lastAssistantMessage = msg.result;
309
+ await onProgress?.(0, undefined, `Research complete (${msg.num_turns} turns)`);
285
310
  console.error(`[research-agent] Completed in ${msg.num_turns} turns`);
286
311
  }
287
312
  else if (msg.subtype?.startsWith('error')) {
288
313
  console.error(`[research-agent] Error: ${msg.subtype}`, msg.errors);
289
314
  }
290
315
  }
291
- // Log tool usage for debugging
316
+ // Log tool results via the summary message
292
317
  if (message.type === 'tool_use_summary') {
293
318
  const msg = message;
294
- console.error(`[research-agent] Tool: ${msg.tool_name || 'unknown'}`);
319
+ if (msg.summary) {
320
+ // The summary often contains "Found X results" or similar
321
+ const summarySnippet = msg.summary.substring(0, 150).replace(/\n/g, ' ');
322
+ await onProgress?.(0, undefined, `Result: ${summarySnippet}`);
323
+ }
324
+ console.error(`[research-agent] Tool complete (turn ${turnCount})`);
295
325
  }
296
326
  }
297
327
  // Parse the final result from the agent's output
@@ -6,17 +6,36 @@
6
6
  * 2. SIMPLE (fallback): Single-pass search + GPT-4o-mini synthesis
7
7
  *
8
8
  * Set LORE_RESEARCH_MODE=simple to use the fallback mode.
9
+ *
10
+ * MCP integration: Research runs asynchronously. The `research` tool returns
11
+ * immediately with a job_id. Use `research_status` to poll for results.
9
12
  */
10
13
  import type { ResearchPackage } from '../../core/types.js';
14
+ /**
15
+ * Start research asynchronously and return a job ID immediately.
16
+ */
17
+ export declare function startResearchJob(dbPath: string, dataDir: string, args: ResearchArgs, options?: {
18
+ hookContext?: {
19
+ mode: 'mcp' | 'cli';
20
+ };
21
+ onProgress?: ProgressCallback;
22
+ }): {
23
+ job_id: string;
24
+ status: string;
25
+ message: string;
26
+ };
27
+ export declare function getResearchJobStatus(jobId: string): Promise<Record<string, unknown>>;
11
28
  interface ResearchArgs {
12
29
  task: string;
13
30
  project?: string;
14
31
  content_type?: string;
15
32
  include_sources?: boolean;
16
33
  }
34
+ export type ProgressCallback = (progress: number, total?: number, message?: string) => Promise<void>;
17
35
  export declare function handleResearch(dbPath: string, dataDir: string, args: ResearchArgs, options?: {
18
36
  hookContext?: {
19
37
  mode: 'mcp' | 'cli';
20
38
  };
39
+ onProgress?: ProgressCallback;
21
40
  }): Promise<ResearchPackage>;
22
41
  export {};
@@ -6,13 +6,145 @@
6
6
  * 2. SIMPLE (fallback): Single-pass search + GPT-4o-mini synthesis
7
7
  *
8
8
  * Set LORE_RESEARCH_MODE=simple to use the fallback mode.
9
+ *
10
+ * MCP integration: Research runs asynchronously. The `research` tool returns
11
+ * immediately with a job_id. Use `research_status` to poll for results.
9
12
  */
10
13
  import OpenAI from 'openai';
14
+ import { randomUUID } from 'crypto';
11
15
  import { searchSources } from '../../core/vector-store.js';
12
16
  import { generateEmbedding } from '../../core/embedder.js';
13
17
  import { loadArchivedProjects } from './archive-project.js';
14
18
  import { runResearchAgent } from './research-agent.js';
15
19
  import { getExtensionRegistry } from '../../extensions/registry.js';
20
+ const jobStore = new Map();
21
+ // Clean up old jobs after 10 minutes
22
+ const JOB_TTL_MS = 10 * 60 * 1000;
23
+ function cleanOldJobs() {
24
+ const now = Date.now();
25
+ for (const [id, job] of jobStore) {
26
+ const startTime = new Date(job.startedAt).getTime();
27
+ if (now - startTime > JOB_TTL_MS) {
28
+ jobStore.delete(id);
29
+ }
30
+ }
31
+ }
32
+ /**
33
+ * Start research asynchronously and return a job ID immediately.
34
+ */
35
+ export function startResearchJob(dbPath, dataDir, args, options = {}) {
36
+ cleanOldJobs();
37
+ const jobId = randomUUID();
38
+ const now = new Date().toISOString();
39
+ const job = {
40
+ id: jobId,
41
+ task: args.task,
42
+ project: args.project,
43
+ status: 'running',
44
+ startedAt: now,
45
+ lastActivityAt: now,
46
+ activity: ['Starting research...'],
47
+ };
48
+ jobStore.set(jobId, job);
49
+ // Fire and forget — runs in the background
50
+ handleResearch(dbPath, dataDir, args, {
51
+ ...options,
52
+ onProgress: async (_p, _t, message) => {
53
+ const j = jobStore.get(jobId);
54
+ if (j && message) {
55
+ j.activity.push(message);
56
+ j.lastActivityAt = new Date().toISOString();
57
+ }
58
+ },
59
+ })
60
+ .then((result) => {
61
+ const j = jobStore.get(jobId);
62
+ if (j) {
63
+ j.status = 'complete';
64
+ j.completedAt = new Date().toISOString();
65
+ j.result = result;
66
+ j.activity.push('Research complete');
67
+ }
68
+ })
69
+ .catch((err) => {
70
+ const j = jobStore.get(jobId);
71
+ if (j) {
72
+ j.status = 'error';
73
+ j.completedAt = new Date().toISOString();
74
+ j.error = err instanceof Error ? err.message : String(err);
75
+ j.activity.push(`Failed: ${j.error}`);
76
+ }
77
+ })
78
+ .catch((err) => {
79
+ // Final safety net for errors in the handlers above
80
+ console.error(`[research] Critical error in job ${jobId}:`, err);
81
+ });
82
+ return {
83
+ job_id: jobId,
84
+ status: 'running',
85
+ message: `Research started for: "${args.task}". Poll research_status with job_id "${jobId}" every 15-20 seconds. This typically takes 2-8 minutes — do not abandon early.`,
86
+ };
87
+ }
88
+ /**
89
+ * Check status of a research job.
90
+ * Long-polls for up to POLL_WAIT_MS, returning early if the job completes.
91
+ */
92
+ const POLL_WAIT_MS = 20_000;
93
+ const POLL_INTERVAL_MS = 1_000;
94
+ export async function getResearchJobStatus(jobId) {
95
+ let job = jobStore.get(jobId);
96
+ if (!job) {
97
+ return { status: 'not_found', job_id: jobId };
98
+ }
99
+ // If already done, return immediately
100
+ if (job.status !== 'running') {
101
+ return formatJobResponse(job);
102
+ }
103
+ // Long-poll: wait up to POLL_WAIT_MS for completion, checking every second
104
+ const deadline = Date.now() + POLL_WAIT_MS;
105
+ while (Date.now() < deadline) {
106
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
107
+ // Re-fetch to avoid stale reference if job was cleaned up
108
+ job = jobStore.get(jobId);
109
+ if (!job) {
110
+ return { status: 'not_found', job_id: jobId };
111
+ }
112
+ if (job.status !== 'running') {
113
+ return formatJobResponse(job);
114
+ }
115
+ }
116
+ return formatJobResponse(job);
117
+ }
118
+ function formatJobResponse(job) {
119
+ const elapsed = Math.round((Date.now() - new Date(job.startedAt).getTime()) / 1000);
120
+ if (job.status === 'complete') {
121
+ return {
122
+ status: 'complete',
123
+ job_id: job.id,
124
+ task: job.task,
125
+ elapsed_seconds: elapsed,
126
+ result: job.result,
127
+ };
128
+ }
129
+ if (job.status === 'error') {
130
+ return {
131
+ status: 'error',
132
+ job_id: job.id,
133
+ task: job.task,
134
+ elapsed_seconds: elapsed,
135
+ error: job.error,
136
+ };
137
+ }
138
+ return {
139
+ status: 'running',
140
+ job_id: job.id,
141
+ task: job.task,
142
+ elapsed_seconds: elapsed,
143
+ total_steps: job.activity.length,
144
+ activity: job.activity,
145
+ message: `Research is still running (${elapsed}s elapsed, ${job.activity.length} steps completed). This is normal — deep research takes 2-8 minutes. Keep polling.`,
146
+ };
147
+ }
16
148
  // Lazy initialization for OpenAI (only used in simple mode)
17
149
  let openaiClient = null;
18
150
  function getOpenAI() {
@@ -109,12 +241,15 @@ Respond with only the JSON object.`;
109
241
  }
110
242
  export async function handleResearch(dbPath, dataDir, args, options = {}) {
111
243
  const { task, project, include_sources = true } = args;
244
+ const { onProgress } = options;
112
245
  // Check if we should use agentic mode (default) or simple mode (fallback)
113
246
  const useAgenticMode = process.env.LORE_RESEARCH_MODE !== 'simple';
114
247
  if (useAgenticMode) {
115
248
  console.error('[research] Using agentic mode (Claude Agent SDK)');
249
+ await onProgress?.(0, undefined, 'Starting agentic research...');
116
250
  try {
117
- const result = await runResearchAgent(dbPath, dataDir, args);
251
+ const result = await runResearchAgent(dbPath, dataDir, args, onProgress);
252
+ await onProgress?.(100, 100, 'Research complete');
118
253
  await runResearchCompletedHook(result, {
119
254
  mode: options.hookContext?.mode || 'mcp',
120
255
  dataDir,
@@ -124,11 +259,14 @@ export async function handleResearch(dbPath, dataDir, args, options = {}) {
124
259
  }
125
260
  catch (error) {
126
261
  console.error('[research] Agentic mode failed, falling back to simple mode:', error);
262
+ await onProgress?.(0, undefined, 'Agentic mode failed, falling back to simple mode...');
127
263
  // Fall through to simple mode
128
264
  }
129
265
  }
130
266
  console.error('[research] Using simple mode (single-pass synthesis)');
131
- const result = await handleResearchSimple(dbPath, dataDir, args);
267
+ await onProgress?.(0, undefined, 'Starting simple research...');
268
+ const result = await handleResearchSimple(dbPath, dataDir, args, onProgress);
269
+ await onProgress?.(100, 100, 'Research complete');
132
270
  await runResearchCompletedHook(result, {
133
271
  mode: options.hookContext?.mode || 'mcp',
134
272
  dataDir,
@@ -140,7 +278,7 @@ export async function handleResearch(dbPath, dataDir, args, options = {}) {
140
278
  * Simple research mode - single pass search + synthesis
141
279
  * This is the fallback when agentic mode fails or is disabled
142
280
  */
143
- async function handleResearchSimple(dbPath, dataDir, args) {
281
+ async function handleResearchSimple(dbPath, dataDir, args, onProgress) {
144
282
  const { task, project, include_sources = true } = args;
145
283
  // Use sensible defaults for simple mode
146
284
  const sourceLimit = 10;
@@ -149,7 +287,9 @@ async function handleResearchSimple(dbPath, dataDir, args) {
149
287
  const archivedProjects = await loadArchivedProjects(dataDir);
150
288
  const archivedNames = new Set(archivedProjects.map((p) => p.project.toLowerCase()));
151
289
  // Step 1: Search for relevant sources (fetch extra to account for archived filtering)
290
+ await onProgress?.(10, 100, 'Generating embeddings...');
152
291
  const queryVector = await generateEmbedding(task);
292
+ await onProgress?.(30, 100, 'Searching sources...');
153
293
  const rawSources = await searchSources(dbPath, queryVector, {
154
294
  limit: sourceLimit * 2,
155
295
  project,
@@ -172,6 +312,7 @@ async function handleResearchSimple(dbPath, dataDir, args) {
172
312
  }
173
313
  }
174
314
  // Step 3: Synthesize findings with LLM (conflict-aware)
315
+ await onProgress?.(60, 100, 'Synthesizing findings...');
175
316
  // Note: Decisions are now extracted at query time by the agentic research mode
176
317
  const synthesis = await synthesizeFindings(task, sources.map((s) => ({
177
318
  id: s.id,
@@ -44,5 +44,6 @@ export declare function handleSync(dbPath: string, dataDir: string, args: SyncAr
44
44
  hookContext?: {
45
45
  mode: 'mcp' | 'cli';
46
46
  };
47
+ onProgress?: (progress: number, total?: number, message?: string) => Promise<void>;
47
48
  }): Promise<SyncResult>;
48
49
  export {};
@@ -233,8 +233,10 @@ export async function handleSync(dbPath, dataDir, args, options = {}) {
233
233
  already_indexed: 0,
234
234
  reconciled: 0,
235
235
  };
236
+ const { onProgress } = options;
236
237
  // 1. Git pull
237
238
  if (doPull) {
239
+ await onProgress?.(5, undefined, 'Pulling from git...');
238
240
  const pullResult = await gitPull(dataDir);
239
241
  result.git_pulled = pullResult.success && (pullResult.message?.includes('Pulled') || false);
240
242
  if (pullResult.error) {
@@ -248,17 +250,20 @@ export async function handleSync(dbPath, dataDir, args, options = {}) {
248
250
  const hasUniversalSources = getEnabledSources(config).length > 0;
249
251
  if (hasUniversalSources && !useLegacy) {
250
252
  // Use new universal sync
253
+ await onProgress?.(20, undefined, 'Discovering new files...');
251
254
  const { discovery, processing } = await universalSync(dataDir, dryRun, options.hookContext);
252
255
  result.discovery = discovery;
253
256
  result.processing = processing;
254
257
  }
255
258
  // Always run legacy disk sync for backward compatibility
256
259
  // (picks up sources added via old `lore ingest` command)
260
+ await onProgress?.(60, undefined, 'Running legacy sync...');
257
261
  const legacyResult = await legacyDiskSync(dbPath, dataDir);
258
262
  result.sources_found = legacyResult.sources_found;
259
263
  result.sources_indexed = legacyResult.sources_indexed;
260
264
  result.already_indexed = legacyResult.already_indexed;
261
265
  // Reconcile: ensure every Supabase source has local content.md
266
+ await onProgress?.(80, undefined, 'Reconciling local content...');
262
267
  result.reconciled = await reconcileLocalContent(dataDir);
263
268
  }
264
269
  // 3. Git push
@@ -21,7 +21,7 @@ import { handleGetSource } from './handlers/get-source.js';
21
21
  import { handleListSources } from './handlers/list-sources.js';
22
22
  import { handleRetain } from './handlers/retain.js';
23
23
  import { handleIngest } from './handlers/ingest.js';
24
- import { handleResearch } from './handlers/research.js';
24
+ import { startResearchJob, getResearchJobStatus } from './handlers/research.js';
25
25
  import { handleListProjects } from './handlers/list-projects.js';
26
26
  import { handleSync } from './handlers/sync.js';
27
27
  import { handleArchiveProject } from './handlers/archive-project.js';
@@ -136,7 +136,7 @@ async function main() {
136
136
  }
137
137
  const server = new Server({
138
138
  name: 'lore',
139
- version: '0.1.0',
139
+ version: '0.7.0',
140
140
  }, {
141
141
  capabilities: {
142
142
  tools: {},
@@ -184,8 +184,25 @@ async function main() {
184
184
  return { tools: toolDefinitions };
185
185
  });
186
186
  // Handle tool calls (core tools only)
187
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
187
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
188
188
  const { name, arguments: args } = request.params;
189
+ // Build a progress callback for long-running tools.
190
+ // If the client sent a progressToken, we send notifications/progress back;
191
+ // otherwise, onProgress is a no-op.
192
+ const progressToken = request.params._meta?.progressToken;
193
+ const onProgress = progressToken
194
+ ? async (progress, total, message) => {
195
+ try {
196
+ await extra.sendNotification({
197
+ method: 'notifications/progress',
198
+ params: { progressToken, progress, ...(total != null ? { total } : {}), ...(message ? { message } : {}) },
199
+ });
200
+ }
201
+ catch {
202
+ // Progress notifications are best-effort
203
+ }
204
+ }
205
+ : undefined;
189
206
  try {
190
207
  let result;
191
208
  switch (name) {
@@ -215,16 +232,22 @@ async function main() {
215
232
  hookContext: { mode: 'mcp' },
216
233
  });
217
234
  break;
218
- // Agentic research tool (uses Claude Agent SDK internally)
235
+ // Agentic research tool runs async, returns job_id immediately
219
236
  case 'research':
220
- result = await handleResearch(DB_PATH, LORE_DATA_DIR, args, {
237
+ result = startResearchJob(DB_PATH, LORE_DATA_DIR, args, {
221
238
  hookContext: { mode: 'mcp' },
239
+ onProgress,
222
240
  });
223
241
  break;
242
+ // Poll for research results (long-polls up to 20s)
243
+ case 'research_status':
244
+ result = await getResearchJobStatus(args?.job_id);
245
+ break;
224
246
  // Sync tool
225
247
  case 'sync':
226
248
  result = await handleSync(DB_PATH, LORE_DATA_DIR, args, {
227
249
  hookContext: { mode: 'mcp' },
250
+ onProgress,
228
251
  });
229
252
  break;
230
253
  // Project management
package/dist/mcp/tools.js CHANGED
@@ -241,7 +241,7 @@ USE 'ingest' INSTEAD for full documents, meeting notes, transcripts, or any cont
241
241
  name: 'research',
242
242
  description: `Run a comprehensive research query across the knowledge base. An internal agent iteratively searches, reads sources, cross-references findings, and synthesizes a research package with full citations.
243
243
 
244
- Returns: summary, key findings, supporting quotes with citations, conflicts detected between sources, and suggested follow-up queries.
244
+ ASYNC: This tool returns immediately with a job_id. You MUST then poll 'research_status' with that job_id to get results. Research typically takes 2-8 minutes depending on the amount of data. Poll every 15-20 seconds. Do NOT assume it is stuck — check the 'activity' array in the status response to see what the agent is doing.
245
245
 
246
246
  WHEN TO USE:
247
247
  - Questions that span multiple sources ("What do we know about authentication?")
@@ -249,9 +249,23 @@ WHEN TO USE:
249
249
  - Building a cited research package for decision-making
250
250
  - Open-ended exploration of a topic
251
251
 
252
- COST: This tool makes multiple LLM calls internally (typically 3-8 search + read cycles). For simple lookups, use 'search' instead — it's 10x cheaper and faster.`,
252
+ COST: This tool makes multiple LLM calls internally (typically 10-30 search + read cycles). For simple lookups, use 'search' instead — it's 10x cheaper and faster.`,
253
253
  inputSchema: zodToJsonSchema(ResearchSchema),
254
254
  },
255
+ // Research status (polling for async results)
256
+ {
257
+ name: 'research_status',
258
+ description: `Check the status of a running research job. Returns the full research package when complete.
259
+
260
+ Call this after 'research' returns a job_id. Research typically takes 2-8 minutes. Poll every 15-20 seconds. The response includes an 'activity' array showing exactly what the research agent is doing (searches, sources being read, reasoning). As long as 'total_steps' is increasing or 'elapsed_seconds' is under 8 minutes, the research is progressing normally — do NOT abandon it.`,
261
+ inputSchema: {
262
+ type: 'object',
263
+ properties: {
264
+ job_id: { type: 'string', description: 'The job_id returned by the research tool' },
265
+ },
266
+ required: ['job_id'],
267
+ },
268
+ },
255
269
  // Ingest tool
256
270
  {
257
271
  name: 'ingest',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getlore/cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Research knowledge repository with semantic search, citations, and project lineage tracking",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",