wayfind 2.0.69 → 2.0.70

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.
@@ -302,6 +302,92 @@ async function call(config, systemPrompt, userContent) {
302
302
  }
303
303
  }
304
304
 
305
+ // ── Tool-use relay ──────────────────────────────────────────────────────────
306
+
307
+ /**
308
+ * Call the Anthropic API with tool-use support, looping until the model stops.
309
+ * @param {Object} config - Provider configuration (must be Anthropic)
310
+ * @param {string} systemPrompt - System prompt
311
+ * @param {string} userContent - User message text
312
+ * @param {Array} tools - Anthropic tool-use format tool definitions
313
+ * @param {Function} handleToolCall - async (name, input) => result
314
+ * @returns {Promise<string>} - Final text response
315
+ */
316
+ async function callWithTools(config, systemPrompt, userContent, tools, handleToolCall) {
317
+ const apiKey = process.env[config.api_key_env];
318
+ if (!apiKey) {
319
+ throw new Error(`Anthropic: Missing API key. Set ${config.api_key_env} environment variable.`);
320
+ }
321
+
322
+ const headers = {
323
+ 'x-api-key': apiKey,
324
+ 'anthropic-version': ANTHROPIC_VERSION,
325
+ };
326
+
327
+ const MAX_ITERATIONS = 10;
328
+ let messages = [{ role: 'user', content: userContent }];
329
+
330
+ for (let i = 0; i < MAX_ITERATIONS; i++) {
331
+ const payload = JSON.stringify({
332
+ model: config.model,
333
+ max_tokens: config.max_tokens || DEFAULT_MAX_TOKENS,
334
+ system: systemPrompt,
335
+ messages,
336
+ tools,
337
+ });
338
+
339
+ const res = await httpPost(ANTHROPIC_API_URL, headers, payload);
340
+ checkResponse(res, 'Anthropic');
341
+
342
+ let data;
343
+ try {
344
+ data = JSON.parse(res.body);
345
+ } catch {
346
+ throw new Error('Anthropic: Failed to parse response JSON.');
347
+ }
348
+
349
+ if (!data.content || !Array.isArray(data.content)) {
350
+ throw new Error('Anthropic: Response missing content array.');
351
+ }
352
+
353
+ // If the model is done, extract and return the final text
354
+ if (data.stop_reason !== 'tool_use') {
355
+ const textBlock = data.content.find(b => b.type === 'text');
356
+ return textBlock ? textBlock.text : '';
357
+ }
358
+
359
+ // Process tool calls
360
+ const toolUseBlocks = data.content.filter(b => b.type === 'tool_use');
361
+ if (toolUseBlocks.length === 0) {
362
+ // stop_reason is tool_use but no tool_use blocks — treat as done
363
+ const textBlock = data.content.find(b => b.type === 'text');
364
+ return textBlock ? textBlock.text : '';
365
+ }
366
+
367
+ // Build tool results
368
+ const toolResults = [];
369
+ for (const block of toolUseBlocks) {
370
+ let result;
371
+ try {
372
+ result = await handleToolCall(block.name, block.input);
373
+ } catch (err) {
374
+ result = { error: err.message };
375
+ }
376
+ toolResults.push({
377
+ type: 'tool_result',
378
+ tool_use_id: block.id,
379
+ content: typeof result === 'string' ? result : JSON.stringify(result),
380
+ });
381
+ }
382
+
383
+ // Append assistant message + tool results, then loop
384
+ messages.push({ role: 'assistant', content: data.content });
385
+ messages.push({ role: 'user', content: toolResults });
386
+ }
387
+
388
+ throw new Error('Anthropic: Tool-use loop exceeded maximum iterations (10).');
389
+ }
390
+
305
391
  // ── Auto-detect available provider ───────────────────────────────────────────
306
392
 
307
393
  /**
@@ -535,6 +621,7 @@ async function generateEmbeddingAzure(text, options = {}) {
535
621
 
536
622
  module.exports = {
537
623
  call,
624
+ callWithTools,
538
625
  detect,
539
626
  generateEmbedding,
540
627
  getEmbeddingProviderInfo,
@@ -610,7 +610,7 @@ async function indexJournals(options = {}) {
610
610
 
611
611
  /**
612
612
  * Search journals using semantic similarity.
613
- * Falls back to searchText() if no embeddings available.
613
+ * Falls back to queryMetadata() browse if no embeddings available.
614
614
  * @param {string} query - Search query
615
615
  * @param {Object} [options]
616
616
  * @param {string} [options.storePath] - Content store directory
@@ -633,7 +633,11 @@ async function searchJournals(query, options = {}) {
633
633
  const hasEmbeddings = Object.keys(embeddings).length > 0;
634
634
 
635
635
  if (!hasEmbeddings) {
636
- return searchText(query, options);
636
+ const browseResults = queryMetadata(options);
637
+ return browseResults.slice(0, limit).map(r => ({
638
+ ...r, score: null,
639
+ _hint: 'Semantic search unavailable — showing recent entries by date. Run "wayfind reindex" to enable semantic search.',
640
+ }));
637
641
  }
638
642
 
639
643
  // Generate query embedding
@@ -641,8 +645,12 @@ async function searchJournals(query, options = {}) {
641
645
  try {
642
646
  queryVec = await llm.generateEmbedding(query);
643
647
  } catch {
644
- // Fall back to text search if embedding fails
645
- return searchText(query, options);
648
+ // Fall back to browse if embedding generation fails
649
+ const browseResults = queryMetadata(options);
650
+ return browseResults.slice(0, limit).map(r => ({
651
+ ...r, score: null,
652
+ _hint: 'Semantic search unavailable — showing recent entries by date. Run "wayfind reindex" to enable semantic search.',
653
+ }));
646
654
  }
647
655
 
648
656
  // Score all entries
@@ -662,143 +670,6 @@ async function searchJournals(query, options = {}) {
662
670
  return results.slice(0, limit);
663
671
  }
664
672
 
665
- /**
666
- * Full-text search across journal entries.
667
- * Works without any API key. Matches query words against title, repo, tags.
668
- * @param {string} query - Search query
669
- * @param {Object} [options]
670
- * @param {string} [options.storePath] - Content store directory
671
- * @param {number} [options.limit] - Max results (default: 10)
672
- * @param {string} [options.repo] - Filter by repo
673
- * @param {string} [options.since] - Filter by date (YYYY-MM-DD)
674
- * @param {string} [options.until] - Filter by date (YYYY-MM-DD)
675
- * @param {boolean} [options.drifted] - Filter by drift status
676
- * @returns {Array<{ id: string, score: number, entry: Object }>}
677
- */
678
- function searchText(query, options = {}) {
679
- const storePath = options.storePath || resolveStorePath();
680
- const journalDir = options.journalDir || DEFAULT_JOURNAL_DIR;
681
- const limit = options.limit || 10;
682
-
683
- const index = getBackend(storePath).loadIndex();
684
- if (!index) return [];
685
-
686
- // Normalize: split on whitespace, hyphens, underscores
687
- const queryWords = query.toLowerCase().split(/[\s\-_]+/).filter(w => w.length > 1);
688
- if (queryWords.length === 0) return [];
689
-
690
- // Pre-load journal content for full-text search (cache by date+user key)
691
- const journalCache = {};
692
- function getJournalContent(date, user) {
693
- const cacheKey = user ? `${date}-${user}` : date;
694
- if (journalCache[cacheKey] !== undefined) return journalCache[cacheKey];
695
- if (!journalDir) { journalCache[cacheKey] = null; return null; }
696
- // Try authored filename first, then plain date filename
697
- const candidates = user
698
- ? [path.join(journalDir, `${date}-${user}.md`), path.join(journalDir, `${date}.md`)]
699
- : [path.join(journalDir, `${date}.md`)];
700
- let content = null;
701
- for (const filePath of candidates) {
702
- try {
703
- content = fs.readFileSync(filePath, 'utf8').toLowerCase();
704
- break;
705
- } catch {
706
- // Try next candidate
707
- }
708
- }
709
- journalCache[cacheKey] = content;
710
- return content;
711
- }
712
-
713
- const results = [];
714
- for (const [id, entry] of Object.entries(index.entries)) {
715
- if (!applyFilters(entry, options)) continue;
716
-
717
- // Build searchable text from entry metadata (normalize hyphens/underscores)
718
- let searchable = [
719
- entry.title,
720
- entry.repo,
721
- entry.date,
722
- entry.user,
723
- ...(entry.tags || []),
724
- ].filter(Boolean).join(' ').toLowerCase().replace(/[-_]/g, ' ');
725
-
726
- // For signal entries, read content directly from the signal file
727
- if (entry.source === 'signal') {
728
- const signalsDir = options.signalsDir || resolveSignalsDir();
729
- if (signalsDir) {
730
- // Signal files live at signalsDir/<channel>/<date>.md or signalsDir/<channel>/<owner>/<repo>/<date>.md
731
- // The repo field tells us the path: "signals/<channel>" or "<owner>/<repo>"
732
- const repo = entry.repo || '';
733
- const candidates = [];
734
- if (repo.startsWith('signals/')) {
735
- const channel = repo.replace('signals/', '');
736
- candidates.push(path.join(signalsDir, channel, `${entry.date}.md`));
737
- candidates.push(path.join(signalsDir, channel, `${entry.date}-summary.md`));
738
- } else if (repo.includes('/')) {
739
- // owner/repo format — find which channel it's under
740
- for (const channel of ['github', 'intercom', 'notion']) {
741
- candidates.push(path.join(signalsDir, channel, repo, `${entry.date}.md`));
742
- }
743
- }
744
- for (const fp of candidates) {
745
- try {
746
- const content = fs.readFileSync(fp, 'utf8').toLowerCase();
747
- searchable += ' ' + content.replace(/[-_]/g, ' ');
748
- break;
749
- } catch {
750
- // Try next candidate
751
- }
752
- }
753
- }
754
- }
755
-
756
- // Also include the full journal entry content if available
757
- const journalContent = entry.source !== 'signal' ? getJournalContent(entry.date, entry.user) : null;
758
- if (journalContent) {
759
- // Find this entry's section in the journal file.
760
- // Try exact match first, then normalize hyphens/spaces for fuzzy match.
761
- const repoTitle = `${entry.repo} — ${entry.title}`.toLowerCase();
762
- let idx = journalContent.indexOf(repoTitle);
763
- if (idx === -1) {
764
- // Normalize both sides: collapse hyphens, underscores, em-dashes, and extra spaces
765
- const norm = (s) => s.replace(/[-_\u2014\u2013]/g, ' ').replace(/\s+/g, ' ').trim();
766
- const normalized = norm(repoTitle);
767
- // Search through journal headers for a normalized match
768
- const headerRegex = /\n## (.+)/g;
769
- let match;
770
- while ((match = headerRegex.exec(journalContent)) !== null) {
771
- const headerNorm = norm(match[1]);
772
- if (headerNorm.includes(normalized) || normalized.includes(headerNorm)) {
773
- idx = match.index + 1; // skip the \n
774
- break;
775
- }
776
- }
777
- }
778
- if (idx !== -1) {
779
- // Extract from header to next header (or end of file)
780
- const nextHeader = journalContent.indexOf('\n## ', idx + 1);
781
- const section = nextHeader !== -1 ? journalContent.slice(idx, nextHeader) : journalContent.slice(idx);
782
- searchable += ' ' + section.replace(/[-_]/g, ' ');
783
- }
784
- }
785
-
786
- // Score: count of matching query words
787
- let matches = 0;
788
- for (const word of queryWords) {
789
- if (searchable.includes(word)) matches++;
790
- }
791
-
792
- if (matches > 0) {
793
- const score = Math.round((matches / queryWords.length) * 1000) / 1000;
794
- results.push({ id, score, entry });
795
- }
796
- }
797
-
798
- results.sort((a, b) => b.score - a.score);
799
- return results.slice(0, limit);
800
- }
801
-
802
673
  /**
803
674
  * Apply metadata filters to an entry.
804
675
  * @param {Object} entry
@@ -2480,7 +2351,6 @@ module.exports = {
2480
2351
  applyContextShiftToState,
2481
2352
  generateOnboardingPack,
2482
2353
  searchJournals,
2483
- searchText,
2484
2354
  queryMetadata,
2485
2355
  extractInsights,
2486
2356
  computeQualityProfile,
package/bin/mcp-server.js CHANGED
@@ -228,43 +228,32 @@ async function proxyGetEntry(id) {
228
228
  const TOOLS = [
229
229
  {
230
230
  name: 'search_context',
231
- description: 'Search the team\'s full decision history across all repos and all engineers. Returns ranked journal entries, decisions, and signals. Use this not file reads to answer any question about past work, architectural decisions, what was decided, or team activity. The content store covers history that state files cannot.',
231
+ description: 'Search the team\'s decision history across all repos and engineers. Returns ranked entries. Use mode=browse with since/until for time-range queries ("what happened this week"). Use mode=semantic with a query for topical searches. Pass dates, authors, and repos as explicit parameters.',
232
232
  inputSchema: {
233
233
  type: 'object',
234
234
  properties: {
235
- query: { type: 'string', description: 'Natural language search query' },
235
+ query: { type: 'string', description: 'Natural language search query (required for semantic mode, optional for browse)' },
236
236
  limit: { type: 'number', description: 'Max results (default: 10)' },
237
237
  repo: { type: 'string', description: 'Filter by repository name (e.g. "MyService", "MyOrg/my-repo")' },
238
238
  since: { type: 'string', description: 'Filter to entries on or after this date (YYYY-MM-DD)' },
239
- mode: { type: 'string', enum: ['semantic', 'text'], description: 'Search mode semantic uses embeddings, text uses keyword matching. Defaults to semantic if embeddings available.' },
239
+ until: { type: 'string', description: 'Filter to entries on or before this date (YYYY-MM-DD)' },
240
+ user: { type: 'string', description: 'Filter by author slug (lowercase first name, e.g. "nick")' },
241
+ source: { type: 'string', enum: ['journal', 'conversation', 'signal'], description: 'Filter by entry source type' },
242
+ mode: { type: 'string', enum: ['semantic', 'browse'], description: 'Search strategy. semantic (default) uses embeddings for relevance ranking. browse returns entries sorted by date (best for time-range queries).' },
240
243
  },
241
- required: ['query'],
242
244
  },
243
245
  },
244
246
  {
245
247
  name: 'get_entry',
246
- description: 'Retrieve the full content of a specific journal or signal entry by ID. Use the IDs returned by search_context or list_recent.',
248
+ description: 'Retrieve the full content of a specific journal or signal entry by ID. Use the IDs returned by search_context.',
247
249
  inputSchema: {
248
250
  type: 'object',
249
251
  properties: {
250
- id: { type: 'string', description: 'Entry ID from search_context or list_recent results' },
252
+ id: { type: 'string', description: 'Entry ID from search_context results' },
251
253
  },
252
254
  required: ['id'],
253
255
  },
254
256
  },
255
- {
256
- name: 'list_recent',
257
- description: 'List recent journal entries and decisions, optionally filtered by repo or date range. Returns metadata (no full content — use get_entry for that).',
258
- inputSchema: {
259
- type: 'object',
260
- properties: {
261
- limit: { type: 'number', description: 'Max entries to return (default: 20)' },
262
- repo: { type: 'string', description: 'Filter by repository name' },
263
- since: { type: 'string', description: 'Filter to entries on or after this date (YYYY-MM-DD)' },
264
- source: { type: 'string', enum: ['journal', 'conversation', 'signal'], description: 'Filter by entry source type' },
265
- },
266
- },
267
- },
268
257
  {
269
258
  name: 'get_signals',
270
259
  description: 'Retrieve recent signal entries (GitHub activity, Slack summaries, Intercom updates, Notion pages) for a specific channel or all channels.',
@@ -327,26 +316,53 @@ const TOOLS = [
327
316
  // ── Tool handlers ────────────────────────────────────────────────────────────
328
317
 
329
318
  async function handleSearchContext(args) {
330
- const { query, limit = 10, repo, since, mode } = args;
319
+ const { query, limit = 10, repo, since, until, user, source, mode: rawMode } = args;
320
+
321
+ // Auto-switch to browse if no query provided
322
+ const mode = (!query && rawMode !== 'browse') ? 'browse' : (rawMode || 'semantic');
331
323
 
332
- // Try container first for semantic search (has embeddings for full team)
333
- if (mode !== 'text') {
334
- const containerResult = await proxySearch({ query, limit, repo, since, mode });
324
+ // Browse mode return entries sorted by date (no embeddings needed)
325
+ if (mode === 'browse') {
326
+ const opts = { limit, repo, since, until, user, source };
327
+
328
+ // Try container first
329
+ const containerResult = await proxySearch({ query, limit, repo, since, until, user, source, mode: 'browse' });
335
330
  if (containerResult && containerResult.found > 0) {
336
331
  containerResult.source = 'container';
337
332
  return containerResult;
338
333
  }
334
+
335
+ // Fall back to local
336
+ const results = contentStore.queryMetadata(opts);
337
+ const top = results.slice(0, limit);
338
+ return {
339
+ found: results.length,
340
+ showing: top.length,
341
+ source: 'local',
342
+ results: top.map(r => ({
343
+ id: r.id,
344
+ date: r.entry.date,
345
+ repo: r.entry.repo,
346
+ title: r.entry.title,
347
+ source: r.entry.source,
348
+ user: r.entry.user || null,
349
+ tags: r.entry.tags || [],
350
+ summary: r.entry.summary || null,
351
+ })),
352
+ };
339
353
  }
340
354
 
341
- // Fall back to local search
342
- const opts = { limit, repo, since };
343
- let results;
344
- if (mode === 'text') {
345
- results = contentStore.searchText(query, opts);
346
- } else {
347
- results = await contentStore.searchJournals(query, opts);
355
+ // Semantic mode try container first (has embeddings for full team)
356
+ const containerResult = await proxySearch({ query, limit, repo, since, until, user, source, mode });
357
+ if (containerResult && containerResult.found > 0) {
358
+ containerResult.source = 'container';
359
+ return containerResult;
348
360
  }
349
361
 
362
+ // Fall back to local semantic search
363
+ const opts = { limit, repo, since, until, user, source };
364
+ const results = await contentStore.searchJournals(query, opts);
365
+
350
366
  if (!results || results.length === 0) {
351
367
  return { found: 0, results: [], source: 'local', hint: 'No matches. Try a broader query or check wayfind reindex.' };
352
368
  }
@@ -361,6 +377,7 @@ async function handleSearchContext(args) {
361
377
  repo: r.entry.repo,
362
378
  title: r.entry.title,
363
379
  source: r.entry.source,
380
+ user: r.entry.user || null,
364
381
  tags: r.entry.tags || [],
365
382
  summary: r.entry.summary || null,
366
383
  })),
@@ -397,25 +414,6 @@ async function handleGetEntry(args) {
397
414
  return { error: `Entry not found: ${id}` };
398
415
  }
399
416
 
400
- function handleListRecent(args) {
401
- const { limit = 20, repo, since, source } = args;
402
- const opts = { limit, repo, since, source };
403
- const results = contentStore.queryMetadata(opts);
404
- const top = results.slice(0, limit);
405
-
406
- return {
407
- total: results.length,
408
- showing: top.length,
409
- entries: top.map(r => ({
410
- id: r.id,
411
- date: r.entry.date,
412
- repo: r.entry.repo,
413
- title: r.entry.title,
414
- source: r.entry.source,
415
- tags: r.entry.tags || [],
416
- })),
417
- };
418
- }
419
417
 
420
418
  function handleGetSignals(args) {
421
419
  const { channel, limit = 20 } = args;
@@ -590,7 +588,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
590
588
  switch (name) {
591
589
  case 'search_context': result = await handleSearchContext(args); break;
592
590
  case 'get_entry': result = await handleGetEntry(args); break;
593
- case 'list_recent': result = handleListRecent(args); break;
594
591
  case 'get_signals': result = handleGetSignals(args); break;
595
592
  case 'get_team_status': result = handleGetTeamStatus(args); break;
596
593
  case 'get_personas': result = handleGetPersonas(); break;