recallx-headless 1.0.0 → 1.0.2

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/README.md CHANGED
@@ -132,9 +132,10 @@ recallx workspace open --root /Users/name/Documents/RecallX-Test
132
132
  ## Environment
133
133
 
134
134
  - `RECALLX_API_URL` to override the local API base URL
135
- - `RECALLX_TOKEN` to pass a bearer token
135
+ - `RECALLX_API_TOKEN` to pass a bearer token for CLI requests
136
+ - `RECALLX_TOKEN` remains supported as a legacy alias for CLI requests
136
137
  - `RECALLX_PORT`, `RECALLX_BIND`, `RECALLX_WORKSPACE_ROOT`, `RECALLX_WORKSPACE_NAME`, and `RECALLX_API_TOKEN` are respected by `recallx serve`
137
- - Node 20+ is recommended for the headless package
138
+ - Node 22.13+ is recommended for the headless package
138
139
 
139
140
  ## Notes
140
141
 
@@ -726,7 +726,14 @@ function buildMcpConfigPayload(launcherPath) {
726
726
  }
727
727
  async function installMcpLauncher(launcherPath, commandParts) {
728
728
  await mkdir(path.dirname(launcherPath), { recursive: true });
729
- await writeFile(launcherPath, `#!/bin/sh\nexec ${commandParts.map(quoteShellArg).join(" ")} "$@"\n`, "utf8");
729
+ await writeFile(launcherPath, `#!/bin/sh
730
+ NODE_BIN="\${RECALLX_NODE_BIN:-$(command -v node 2>/dev/null || true)}"
731
+ if [ -z "$NODE_BIN" ]; then
732
+ echo "recallx-mcp launcher could not find a node executable in PATH." >&2
733
+ exit 1
734
+ fi
735
+ exec "$NODE_BIN" ${commandParts.slice(1).map(quoteShellArg).join(" ")} "$@"
736
+ `, "utf8");
730
737
  await chmod(launcherPath, 0o755);
731
738
  }
732
739
  function quoteShellArg(value) {
@@ -202,11 +202,65 @@ export function renderTelemetrySummary(data) {
202
202
  `fts fallback: ${data.ftsFallbackRate.fallbackCount}/${data.ftsFallbackRate.sampleCount} (${data.ftsFallbackRate.ratio ?? "n/a"})`
203
203
  );
204
204
  }
205
+ if (data?.searchHitRate) {
206
+ lines.push(
207
+ `search hit rate: ${data.searchHitRate.hitCount}/${data.searchHitRate.sampleCount} (${data.searchHitRate.ratio ?? "n/a"})`
208
+ );
209
+ const searchHitOps = Array.isArray(data.searchHitRate.operations) ? data.searchHitRate.operations : [];
210
+ for (const item of searchHitOps.slice(0, 5)) {
211
+ lines.push(
212
+ `- [${item.surface}] ${item.operation}: ${item.hitCount}/${item.sampleCount} hits (${item.ratio ?? "n/a"})`
213
+ );
214
+ }
215
+ }
216
+ if (data?.searchLexicalQualityRate) {
217
+ lines.push(
218
+ `lexical quality: strong=${data.searchLexicalQualityRate.strongCount}, weak=${data.searchLexicalQualityRate.weakCount}, none=${data.searchLexicalQualityRate.noneCount}`
219
+ );
220
+ }
221
+ if (data?.workspaceResultCompositionRate) {
222
+ lines.push(
223
+ `workspace composition: node_only=${data.workspaceResultCompositionRate.nodeOnlyCount}, semantic_node_only=${data.workspaceResultCompositionRate.semanticNodeOnlyCount}, activity_only=${data.workspaceResultCompositionRate.activityOnlyCount}, mixed=${data.workspaceResultCompositionRate.mixedCount}, semantic_mixed=${data.workspaceResultCompositionRate.semanticMixedCount}, empty=${data.workspaceResultCompositionRate.emptyCount}`
224
+ );
225
+ }
226
+ if (data?.workspaceFallbackModeRate) {
227
+ lines.push(
228
+ `workspace fallback modes: strict_zero=${data.workspaceFallbackModeRate.strictZeroCount}, no_strong_node_hit=${data.workspaceFallbackModeRate.noStrongNodeHitCount}`
229
+ );
230
+ }
231
+ if (data?.searchFeedbackRate) {
232
+ lines.push(
233
+ `search feedback: useful=${data.searchFeedbackRate.usefulCount}/${data.searchFeedbackRate.sampleCount} (${data.searchFeedbackRate.usefulRatio ?? "n/a"}), top1=${data.searchFeedbackRate.top1UsefulCount}/${data.searchFeedbackRate.top1SampleCount} (${data.searchFeedbackRate.top1UsefulRatio ?? "n/a"}), top3=${data.searchFeedbackRate.top3UsefulCount}/${data.searchFeedbackRate.top3SampleCount} (${data.searchFeedbackRate.top3UsefulRatio ?? "n/a"}), semantic_fp=${data.searchFeedbackRate.semanticFalsePositiveRatio ?? "n/a"}`
234
+ );
235
+ const qualityBuckets = Array.isArray(data.searchFeedbackRate.byLexicalQuality) ? data.searchFeedbackRate.byLexicalQuality : [];
236
+ for (const item of qualityBuckets) {
237
+ lines.push(
238
+ `- feedback [${item.lexicalQuality}]: ${item.usefulCount}/${item.sampleCount} useful (${item.usefulRatio ?? "n/a"})`
239
+ );
240
+ }
241
+ const fallbackModeBuckets = Array.isArray(data.searchFeedbackRate.byFallbackMode) ? data.searchFeedbackRate.byFallbackMode : [];
242
+ for (const item of fallbackModeBuckets) {
243
+ lines.push(
244
+ `- feedback mode [${item.fallbackMode}]: ${item.usefulCount}/${item.sampleCount} useful (${item.usefulRatio ?? "n/a"})`
245
+ );
246
+ }
247
+ }
205
248
  if (data?.semanticAugmentationRate) {
206
249
  lines.push(
207
250
  `semantic augmentation: ${data.semanticAugmentationRate.usedCount}/${data.semanticAugmentationRate.sampleCount} (${data.semanticAugmentationRate.ratio ?? "n/a"})`
208
251
  );
209
252
  }
253
+ if (data?.semanticFallbackRate) {
254
+ lines.push(
255
+ `semantic fallback: eligible=${data.semanticFallbackRate.eligibleCount}, attempted=${data.semanticFallbackRate.attemptedCount}, hit=${data.semanticFallbackRate.hitCount}, hit_ratio=${data.semanticFallbackRate.hitRatio ?? "n/a"}`
256
+ );
257
+ const fallbackModes = Array.isArray(data.semanticFallbackRate.modes) ? data.semanticFallbackRate.modes : [];
258
+ for (const item of fallbackModes) {
259
+ lines.push(
260
+ `- fallback mode [${item.fallbackMode}]: attempted=${item.attemptedCount}/${item.eligibleCount} (${item.attemptRatio ?? "n/a"}), hit=${item.hitCount}/${item.attemptedCount} (${item.hitRatio ?? "n/a"})`
261
+ );
262
+ }
263
+ }
210
264
 
211
265
  return `${lines.join("\n")}\n`;
212
266
  }
@@ -6,7 +6,7 @@ export function getApiBase(argvOptions = {}, env = process.env) {
6
6
  DEFAULT_API_BASE);
7
7
  }
8
8
  export function getAuthToken(argvOptions = {}, env = process.env) {
9
- return argvOptions.token || env.RECALLX_TOKEN || "";
9
+ return argvOptions.token || env.RECALLX_API_TOKEN || env.RECALLX_TOKEN || "";
10
10
  }
11
11
  export async function requestJson(apiBase, path, { method = "GET", token, body } = {}) {
12
12
  const response = await fetch(buildApiUrl(apiBase, path), buildApiRequestInit({ method, token, body }));
package/app/server/app.js CHANGED
@@ -8,7 +8,7 @@ import { activitySearchSchema, appendActivitySchema, appendRelationUsageEventSch
8
8
  import { AppError } from "./errors.js";
9
9
  import { isShortLogLikeAgentNodeInput, maybeCreatePromotionCandidate, recomputeAutomaticGovernance, resolveGovernancePolicy, resolveNodeGovernance, resolveRelationStatus, shouldPromoteActivitySummary } from "./governance.js";
10
10
  import { refreshAutomaticInferredRelationsForNode, reindexAutomaticInferredRelations } from "./inferred-relations.js";
11
- import { createObservabilityWriter, summarizePayloadShape } from "./observability.js";
11
+ import { appendCurrentTelemetryDetails, createObservabilityWriter, summarizePayloadShape } from "./observability.js";
12
12
  import { buildSemanticCandidateBonusMap, buildCandidateRelationBonusMap, buildContextBundle, buildNeighborhoodItems, buildTargetRelatedRetrievalItems, bundleAsMarkdown, computeRankCandidateScore, shouldUseSemanticCandidateAugmentation } from "./retrieval.js";
13
13
  import { buildProjectGraph } from "./project-graph.js";
14
14
  import { createId, isPathWithinRoot } from "./utils.js";
@@ -172,6 +172,9 @@ function parseBooleanSetting(value, fallback) {
172
172
  function parseNumberSetting(value, fallback) {
173
173
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
174
174
  }
175
+ function readWorkspaceSemanticFallbackMode(value) {
176
+ return value === "strict_zero" || value === "no_strong_node_hit" ? value : null;
177
+ }
175
178
  function normalizeApiRequestPath(value) {
176
179
  return value
177
180
  .replace(/^\/artifacts\/.+$/g, "/artifacts/:path")
@@ -1406,6 +1409,12 @@ export function createRecallXApp(params) {
1406
1409
  }, (span) => {
1407
1410
  const searchResult = currentRepository().searchNodes(input);
1408
1411
  span.addDetails({
1412
+ searchHit: searchResult.items.length > 0,
1413
+ bestLexicalQuality: searchResult.items.some((item) => item.lexicalQuality === "strong")
1414
+ ? "strong"
1415
+ : searchResult.items.some((item) => item.lexicalQuality === "weak")
1416
+ ? "weak"
1417
+ : "none",
1409
1418
  resultCount: searchResult.items.length,
1410
1419
  totalCount: searchResult.total
1411
1420
  });
@@ -1423,6 +1432,12 @@ export function createRecallXApp(params) {
1423
1432
  }, (span) => {
1424
1433
  const searchResult = currentRepository().searchActivities(input);
1425
1434
  span.addDetails({
1435
+ searchHit: searchResult.items.length > 0,
1436
+ bestLexicalQuality: searchResult.items.some((item) => item.lexicalQuality === "strong")
1437
+ ? "strong"
1438
+ : searchResult.items.some((item) => item.lexicalQuality === "weak")
1439
+ ? "weak"
1440
+ : "none",
1426
1441
  resultCount: searchResult.items.length,
1427
1442
  totalCount: searchResult.total
1428
1443
  });
@@ -1457,6 +1472,7 @@ export function createRecallXApp(params) {
1457
1472
  })
1458
1473
  });
1459
1474
  span.addDetails({
1475
+ searchHit: searchResult.items.length > 0,
1460
1476
  resultCount: searchResult.items.length,
1461
1477
  totalCount: searchResult.total
1462
1478
  });
@@ -1861,9 +1877,55 @@ export function createRecallXApp(params) {
1861
1877
  governance: buildGovernancePayload(repository, "relation", event.relationId, governanceResult.items[0] ?? repository.getGovernanceStateNullable("relation", event.relationId))
1862
1878
  }));
1863
1879
  });
1864
- app.post("/api/v1/search-feedback-events", (request, response) => {
1880
+ app.post("/api/v1/search-feedback-events", handleAsyncRoute(async (request, response) => {
1865
1881
  const repository = currentRepository();
1866
1882
  const event = repository.appendSearchFeedbackEvent(appendSearchFeedbackSchema.parse(request.body ?? {}));
1883
+ const metadata = event.metadata ?? {};
1884
+ const fallbackMode = readWorkspaceSemanticFallbackMode(metadata.semanticFallbackMode);
1885
+ const lexicalQuality = metadata.lexicalQuality === "strong" || metadata.lexicalQuality === "weak" || metadata.lexicalQuality === "none"
1886
+ ? metadata.lexicalQuality
1887
+ : null;
1888
+ const feedbackRank = typeof metadata.rank === "number" && Number.isFinite(metadata.rank)
1889
+ ? metadata.rank
1890
+ : typeof metadata.resultRank === "number" && Number.isFinite(metadata.resultRank)
1891
+ ? metadata.resultRank
1892
+ : null;
1893
+ const matchStrategy = metadata.matchStrategy === "fts" ||
1894
+ metadata.matchStrategy === "like" ||
1895
+ metadata.matchStrategy === "fallback_token" ||
1896
+ metadata.matchStrategy === "semantic" ||
1897
+ metadata.matchStrategy === "browse"
1898
+ ? metadata.matchStrategy
1899
+ : null;
1900
+ await observability.recordEvent({
1901
+ surface: "api",
1902
+ operation: "search.feedback",
1903
+ details: {
1904
+ feedbackVerdict: event.verdict,
1905
+ feedbackResultType: event.resultType,
1906
+ feedbackLexicalQuality: lexicalQuality,
1907
+ feedbackConfidence: event.confidence,
1908
+ feedbackRank,
1909
+ feedbackMatchStrategy: matchStrategy,
1910
+ feedbackSemanticLifted: metadata.semanticLifted === true,
1911
+ feedbackSemanticFallbackMode: fallbackMode ?? undefined,
1912
+ feedbackHasQuery: Boolean(event.query?.trim()),
1913
+ feedbackHasRunId: Boolean(event.runId),
1914
+ feedbackHasSessionId: Boolean(event.sessionId)
1915
+ }
1916
+ });
1917
+ appendCurrentTelemetryDetails({
1918
+ feedbackVerdict: event.verdict,
1919
+ feedbackResultType: event.resultType,
1920
+ feedbackLexicalQuality: lexicalQuality,
1921
+ feedbackMatchStrategy: matchStrategy,
1922
+ feedbackRank,
1923
+ feedbackSemanticLifted: metadata.semanticLifted === true,
1924
+ feedbackSemanticFallbackMode: fallbackMode ?? undefined,
1925
+ feedbackHasQuery: Boolean(event.query?.trim()),
1926
+ feedbackHasRunId: Boolean(event.runId),
1927
+ feedbackHasSessionId: Boolean(event.sessionId)
1928
+ });
1867
1929
  const governanceResult = event.resultType === "node"
1868
1930
  ? recomputeGovernanceForEntities("node", [event.resultId])
1869
1931
  : { items: [] };
@@ -1878,7 +1940,7 @@ export function createRecallXApp(params) {
1878
1940
  ? buildGovernancePayload(repository, "node", event.resultId, governanceResult.items[0] ?? repository.getGovernanceStateNullable("node", event.resultId))
1879
1941
  : null
1880
1942
  }));
1881
- });
1943
+ }));
1882
1944
  app.post("/api/v1/inferred-relations/recompute", handleAsyncRoute(async (request, response) => {
1883
1945
  const input = recomputeInferredRelationsSchema.parse(request.body ?? {});
1884
1946
  const isFullMaintenancePass = !input.generator && !input.relationIds?.length;
@@ -340,8 +340,44 @@ export class ObservabilityWriter {
340
340
  const buckets = new Map();
341
341
  const mcpFailures = new Map();
342
342
  const autoJobs = new Map();
343
+ const searchHitBuckets = new Map();
344
+ const lexicalQualityBuckets = new Map();
345
+ const workspaceFallbackModeBuckets = new Map();
346
+ const feedbackByLexicalQuality = new Map();
347
+ const feedbackByFallbackMode = new Map();
348
+ const semanticFallbackByMode = new Map();
343
349
  let ftsFallbackCount = 0;
344
350
  let ftsSampleCount = 0;
351
+ let searchHitCount = 0;
352
+ let searchMissCount = 0;
353
+ let searchSampleCount = 0;
354
+ let strongLexicalCount = 0;
355
+ let weakLexicalCount = 0;
356
+ let noLexicalCount = 0;
357
+ let lexicalSampleCount = 0;
358
+ let emptyCompositionCount = 0;
359
+ let nodeOnlyCompositionCount = 0;
360
+ let activityOnlyCompositionCount = 0;
361
+ let mixedCompositionCount = 0;
362
+ let semanticNodeOnlyCompositionCount = 0;
363
+ let semanticMixedCompositionCount = 0;
364
+ let compositionSampleCount = 0;
365
+ let strictZeroFallbackModeCount = 0;
366
+ let noStrongNodeHitFallbackModeCount = 0;
367
+ let workspaceFallbackModeSampleCount = 0;
368
+ let feedbackUsefulCount = 0;
369
+ let feedbackNotUsefulCount = 0;
370
+ let feedbackUncertainCount = 0;
371
+ let feedbackSampleCount = 0;
372
+ let feedbackTop1UsefulCount = 0;
373
+ let feedbackTop1SampleCount = 0;
374
+ let feedbackTop3UsefulCount = 0;
375
+ let feedbackTop3SampleCount = 0;
376
+ let feedbackSemanticUsefulCount = 0;
377
+ let feedbackSemanticNotUsefulCount = 0;
378
+ let feedbackSemanticSampleCount = 0;
379
+ let feedbackSemanticLiftUsefulCount = 0;
380
+ let feedbackSemanticLiftSampleCount = 0;
345
381
  let semanticUsedCount = 0;
346
382
  let semanticSampleCount = 0;
347
383
  let semanticFallbackEligibleCount = 0;
@@ -354,6 +390,209 @@ export class ObservabilityWriter {
354
390
  ftsFallbackCount += 1;
355
391
  }
356
392
  }
393
+ if (typeof event.details.searchHit === "boolean") {
394
+ searchSampleCount += 1;
395
+ if (event.details.searchHit) {
396
+ searchHitCount += 1;
397
+ }
398
+ else {
399
+ searchMissCount += 1;
400
+ }
401
+ const bucketKey = `${event.surface}:${event.operation}`;
402
+ const current = searchHitBuckets.get(bucketKey) ?? {
403
+ surface: event.surface,
404
+ operation: event.operation,
405
+ hitCount: 0,
406
+ missCount: 0
407
+ };
408
+ if (event.details.searchHit) {
409
+ current.hitCount += 1;
410
+ }
411
+ else {
412
+ current.missCount += 1;
413
+ }
414
+ searchHitBuckets.set(bucketKey, current);
415
+ }
416
+ if (event.details.bestLexicalQuality === "strong" ||
417
+ event.details.bestLexicalQuality === "weak" ||
418
+ event.details.bestLexicalQuality === "none" ||
419
+ event.details.bestNodeLexicalQuality === "strong" ||
420
+ event.details.bestNodeLexicalQuality === "weak" ||
421
+ event.details.bestNodeLexicalQuality === "none") {
422
+ const quality = (event.details.bestNodeLexicalQuality ??
423
+ event.details.bestLexicalQuality);
424
+ lexicalSampleCount += 1;
425
+ if (quality === "strong") {
426
+ strongLexicalCount += 1;
427
+ }
428
+ else if (quality === "weak") {
429
+ weakLexicalCount += 1;
430
+ }
431
+ else {
432
+ noLexicalCount += 1;
433
+ }
434
+ const bucketKey = `${event.surface}:${event.operation}`;
435
+ const current = lexicalQualityBuckets.get(bucketKey) ?? {
436
+ surface: event.surface,
437
+ operation: event.operation,
438
+ strongCount: 0,
439
+ weakCount: 0,
440
+ noneCount: 0
441
+ };
442
+ if (quality === "strong") {
443
+ current.strongCount += 1;
444
+ }
445
+ else if (quality === "weak") {
446
+ current.weakCount += 1;
447
+ }
448
+ else {
449
+ current.noneCount += 1;
450
+ }
451
+ lexicalQualityBuckets.set(bucketKey, current);
452
+ }
453
+ if (typeof event.details.resultComposition === "string") {
454
+ compositionSampleCount += 1;
455
+ switch (event.details.resultComposition) {
456
+ case "node_only":
457
+ nodeOnlyCompositionCount += 1;
458
+ break;
459
+ case "activity_only":
460
+ activityOnlyCompositionCount += 1;
461
+ break;
462
+ case "mixed":
463
+ mixedCompositionCount += 1;
464
+ break;
465
+ case "semantic_node_only":
466
+ semanticNodeOnlyCompositionCount += 1;
467
+ break;
468
+ case "semantic_mixed":
469
+ semanticMixedCompositionCount += 1;
470
+ break;
471
+ default:
472
+ emptyCompositionCount += 1;
473
+ break;
474
+ }
475
+ }
476
+ if (event.operation === "workspace.search" &&
477
+ (event.details.semanticFallbackMode === "strict_zero" || event.details.semanticFallbackMode === "no_strong_node_hit")) {
478
+ workspaceFallbackModeSampleCount += 1;
479
+ if (event.details.semanticFallbackMode === "strict_zero") {
480
+ strictZeroFallbackModeCount += 1;
481
+ }
482
+ else {
483
+ noStrongNodeHitFallbackModeCount += 1;
484
+ }
485
+ const bucketKey = `${event.surface}:${event.operation}`;
486
+ const current = workspaceFallbackModeBuckets.get(bucketKey) ?? {
487
+ surface: event.surface,
488
+ operation: event.operation,
489
+ strictZeroCount: 0,
490
+ noStrongNodeHitCount: 0
491
+ };
492
+ if (event.details.semanticFallbackMode === "strict_zero") {
493
+ current.strictZeroCount += 1;
494
+ }
495
+ else {
496
+ current.noStrongNodeHitCount += 1;
497
+ }
498
+ workspaceFallbackModeBuckets.set(bucketKey, current);
499
+ }
500
+ if (event.details.feedbackVerdict === "useful" ||
501
+ event.details.feedbackVerdict === "not_useful" ||
502
+ event.details.feedbackVerdict === "uncertain") {
503
+ feedbackSampleCount += 1;
504
+ if (event.details.feedbackVerdict === "useful") {
505
+ feedbackUsefulCount += 1;
506
+ }
507
+ else if (event.details.feedbackVerdict === "not_useful") {
508
+ feedbackNotUsefulCount += 1;
509
+ }
510
+ else {
511
+ feedbackUncertainCount += 1;
512
+ }
513
+ const feedbackRank = typeof event.details.feedbackRank === "number" && Number.isFinite(event.details.feedbackRank)
514
+ ? event.details.feedbackRank
515
+ : null;
516
+ if (feedbackRank != null) {
517
+ if (feedbackRank <= 1) {
518
+ feedbackTop1SampleCount += 1;
519
+ if (event.details.feedbackVerdict === "useful") {
520
+ feedbackTop1UsefulCount += 1;
521
+ }
522
+ }
523
+ if (feedbackRank <= 3) {
524
+ feedbackTop3SampleCount += 1;
525
+ if (event.details.feedbackVerdict === "useful") {
526
+ feedbackTop3UsefulCount += 1;
527
+ }
528
+ }
529
+ }
530
+ const feedbackMatchStrategy = event.details.feedbackMatchStrategy === "fts" ||
531
+ event.details.feedbackMatchStrategy === "like" ||
532
+ event.details.feedbackMatchStrategy === "fallback_token" ||
533
+ event.details.feedbackMatchStrategy === "semantic" ||
534
+ event.details.feedbackMatchStrategy === "browse"
535
+ ? event.details.feedbackMatchStrategy
536
+ : null;
537
+ if (feedbackMatchStrategy === "semantic") {
538
+ feedbackSemanticSampleCount += 1;
539
+ if (event.details.feedbackVerdict === "useful") {
540
+ feedbackSemanticUsefulCount += 1;
541
+ }
542
+ else if (event.details.feedbackVerdict === "not_useful") {
543
+ feedbackSemanticNotUsefulCount += 1;
544
+ }
545
+ }
546
+ if (event.details.feedbackSemanticLifted === true) {
547
+ feedbackSemanticLiftSampleCount += 1;
548
+ if (event.details.feedbackVerdict === "useful") {
549
+ feedbackSemanticLiftUsefulCount += 1;
550
+ }
551
+ }
552
+ const lexicalQuality = event.details.feedbackLexicalQuality === "strong" ||
553
+ event.details.feedbackLexicalQuality === "weak" ||
554
+ event.details.feedbackLexicalQuality === "none"
555
+ ? event.details.feedbackLexicalQuality
556
+ : null;
557
+ if (lexicalQuality) {
558
+ const current = feedbackByLexicalQuality.get(lexicalQuality) ?? {
559
+ usefulCount: 0,
560
+ notUsefulCount: 0,
561
+ uncertainCount: 0
562
+ };
563
+ if (event.details.feedbackVerdict === "useful") {
564
+ current.usefulCount += 1;
565
+ }
566
+ else if (event.details.feedbackVerdict === "not_useful") {
567
+ current.notUsefulCount += 1;
568
+ }
569
+ else {
570
+ current.uncertainCount += 1;
571
+ }
572
+ feedbackByLexicalQuality.set(lexicalQuality, current);
573
+ }
574
+ const feedbackFallbackMode = event.details.feedbackSemanticFallbackMode === "strict_zero" ||
575
+ event.details.feedbackSemanticFallbackMode === "no_strong_node_hit"
576
+ ? event.details.feedbackSemanticFallbackMode
577
+ : null;
578
+ if (feedbackFallbackMode) {
579
+ const current = feedbackByFallbackMode.get(feedbackFallbackMode) ?? {
580
+ usefulCount: 0,
581
+ notUsefulCount: 0,
582
+ uncertainCount: 0
583
+ };
584
+ if (event.details.feedbackVerdict === "useful") {
585
+ current.usefulCount += 1;
586
+ }
587
+ else if (event.details.feedbackVerdict === "not_useful") {
588
+ current.notUsefulCount += 1;
589
+ }
590
+ else {
591
+ current.uncertainCount += 1;
592
+ }
593
+ feedbackByFallbackMode.set(feedbackFallbackMode, current);
594
+ }
595
+ }
357
596
  if (typeof event.details.semanticUsed === "boolean") {
358
597
  semanticSampleCount += 1;
359
598
  if (event.details.semanticUsed) {
@@ -375,6 +614,29 @@ export class ObservabilityWriter {
375
614
  semanticFallbackHitCount += 1;
376
615
  }
377
616
  }
617
+ const semanticFallbackMode = event.operation === "workspace.search" &&
618
+ (event.details.semanticFallbackMode === "strict_zero" || event.details.semanticFallbackMode === "no_strong_node_hit")
619
+ ? event.details.semanticFallbackMode
620
+ : null;
621
+ if (semanticFallbackMode) {
622
+ const current = semanticFallbackByMode.get(semanticFallbackMode) ?? {
623
+ eligibleCount: 0,
624
+ attemptedCount: 0,
625
+ hitCount: 0,
626
+ sampleCount: 0
627
+ };
628
+ current.sampleCount += 1;
629
+ if (event.details.semanticFallbackEligible === true) {
630
+ current.eligibleCount += 1;
631
+ }
632
+ if (event.details.semanticFallbackAttempted === true) {
633
+ current.attemptedCount += 1;
634
+ }
635
+ if (event.details.semanticFallbackUsed === true) {
636
+ current.hitCount += 1;
637
+ }
638
+ semanticFallbackByMode.set(semanticFallbackMode, current);
639
+ }
378
640
  if (event.durationMs != null) {
379
641
  const bucketKey = `${event.surface}:${event.operation}`;
380
642
  const current = buckets.get(bucketKey) ??
@@ -440,6 +702,108 @@ export class ObservabilityWriter {
440
702
  sampleCount: ftsSampleCount,
441
703
  ratio: ftsSampleCount > 0 ? Number((ftsFallbackCount / ftsSampleCount).toFixed(4)) : null
442
704
  },
705
+ searchHitRate: {
706
+ hitCount: searchHitCount,
707
+ missCount: searchMissCount,
708
+ sampleCount: searchSampleCount,
709
+ ratio: searchSampleCount > 0 ? Number((searchHitCount / searchSampleCount).toFixed(4)) : null,
710
+ operations: [...searchHitBuckets.values()]
711
+ .map((item) => ({
712
+ ...item,
713
+ sampleCount: item.hitCount + item.missCount,
714
+ ratio: item.hitCount + item.missCount > 0
715
+ ? Number((item.hitCount / (item.hitCount + item.missCount)).toFixed(4))
716
+ : null
717
+ }))
718
+ .sort((left, right) => right.sampleCount - left.sampleCount || left.operation.localeCompare(right.operation))
719
+ },
720
+ searchLexicalQualityRate: {
721
+ strongCount: strongLexicalCount,
722
+ weakCount: weakLexicalCount,
723
+ noneCount: noLexicalCount,
724
+ sampleCount: lexicalSampleCount,
725
+ operations: [...lexicalQualityBuckets.values()]
726
+ .map((item) => ({
727
+ ...item,
728
+ sampleCount: item.strongCount + item.weakCount + item.noneCount
729
+ }))
730
+ .sort((left, right) => right.sampleCount - left.sampleCount || left.operation.localeCompare(right.operation))
731
+ },
732
+ workspaceResultCompositionRate: {
733
+ emptyCount: emptyCompositionCount,
734
+ nodeOnlyCount: nodeOnlyCompositionCount,
735
+ activityOnlyCount: activityOnlyCompositionCount,
736
+ mixedCount: mixedCompositionCount,
737
+ semanticNodeOnlyCount: semanticNodeOnlyCompositionCount,
738
+ semanticMixedCount: semanticMixedCompositionCount,
739
+ sampleCount: compositionSampleCount
740
+ },
741
+ workspaceFallbackModeRate: {
742
+ strictZeroCount: strictZeroFallbackModeCount,
743
+ noStrongNodeHitCount: noStrongNodeHitFallbackModeCount,
744
+ sampleCount: workspaceFallbackModeSampleCount,
745
+ operations: [...workspaceFallbackModeBuckets.values()]
746
+ .map((item) => ({
747
+ ...item,
748
+ sampleCount: item.strictZeroCount + item.noStrongNodeHitCount
749
+ }))
750
+ .sort((left, right) => right.sampleCount - left.sampleCount || left.operation.localeCompare(right.operation))
751
+ },
752
+ searchFeedbackRate: {
753
+ usefulCount: feedbackUsefulCount,
754
+ notUsefulCount: feedbackNotUsefulCount,
755
+ uncertainCount: feedbackUncertainCount,
756
+ sampleCount: feedbackSampleCount,
757
+ usefulRatio: feedbackSampleCount > 0 ? Number((feedbackUsefulCount / feedbackSampleCount).toFixed(4)) : null,
758
+ top1UsefulCount: feedbackTop1UsefulCount,
759
+ top1SampleCount: feedbackTop1SampleCount,
760
+ top1UsefulRatio: feedbackTop1SampleCount > 0 ? Number((feedbackTop1UsefulCount / feedbackTop1SampleCount).toFixed(4)) : null,
761
+ top3UsefulCount: feedbackTop3UsefulCount,
762
+ top3SampleCount: feedbackTop3SampleCount,
763
+ top3UsefulRatio: feedbackTop3SampleCount > 0 ? Number((feedbackTop3UsefulCount / feedbackTop3SampleCount).toFixed(4)) : null,
764
+ semanticUsefulCount: feedbackSemanticUsefulCount,
765
+ semanticNotUsefulCount: feedbackSemanticNotUsefulCount,
766
+ semanticSampleCount: feedbackSemanticSampleCount,
767
+ semanticUsefulRatio: feedbackSemanticSampleCount > 0 ? Number((feedbackSemanticUsefulCount / feedbackSemanticSampleCount).toFixed(4)) : null,
768
+ semanticFalsePositiveRatio: feedbackSemanticSampleCount > 0 ? Number((feedbackSemanticNotUsefulCount / feedbackSemanticSampleCount).toFixed(4)) : null,
769
+ semanticLiftUsefulCount: feedbackSemanticLiftUsefulCount,
770
+ semanticLiftSampleCount: feedbackSemanticLiftSampleCount,
771
+ semanticLiftUsefulRatio: feedbackSemanticLiftSampleCount > 0
772
+ ? Number((feedbackSemanticLiftUsefulCount / feedbackSemanticLiftSampleCount).toFixed(4))
773
+ : null,
774
+ byLexicalQuality: ["strong", "weak", "none"]
775
+ .map((lexicalQuality) => {
776
+ const counts = feedbackByLexicalQuality.get(lexicalQuality) ?? {
777
+ usefulCount: 0,
778
+ notUsefulCount: 0,
779
+ uncertainCount: 0
780
+ };
781
+ const sampleCount = counts.usefulCount + counts.notUsefulCount + counts.uncertainCount;
782
+ return {
783
+ lexicalQuality,
784
+ ...counts,
785
+ sampleCount,
786
+ usefulRatio: sampleCount > 0 ? Number((counts.usefulCount / sampleCount).toFixed(4)) : null
787
+ };
788
+ })
789
+ .filter((item) => item.sampleCount > 0),
790
+ byFallbackMode: ["strict_zero", "no_strong_node_hit"]
791
+ .map((fallbackMode) => {
792
+ const counts = feedbackByFallbackMode.get(fallbackMode) ?? {
793
+ usefulCount: 0,
794
+ notUsefulCount: 0,
795
+ uncertainCount: 0
796
+ };
797
+ const sampleCount = counts.usefulCount + counts.notUsefulCount + counts.uncertainCount;
798
+ return {
799
+ fallbackMode,
800
+ ...counts,
801
+ sampleCount,
802
+ usefulRatio: sampleCount > 0 ? Number((counts.usefulCount / sampleCount).toFixed(4)) : null
803
+ };
804
+ })
805
+ .filter((item) => item.sampleCount > 0)
806
+ },
443
807
  semanticAugmentationRate: {
444
808
  usedCount: semanticUsedCount,
445
809
  sampleCount: semanticSampleCount,
@@ -454,7 +818,23 @@ export class ObservabilityWriter {
454
818
  : null,
455
819
  hitRatio: semanticFallbackAttemptedCount > 0
456
820
  ? Number((semanticFallbackHitCount / semanticFallbackAttemptedCount).toFixed(4))
457
- : null
821
+ : null,
822
+ modes: ["strict_zero", "no_strong_node_hit"]
823
+ .map((fallbackMode) => {
824
+ const counts = semanticFallbackByMode.get(fallbackMode) ?? {
825
+ eligibleCount: 0,
826
+ attemptedCount: 0,
827
+ hitCount: 0,
828
+ sampleCount: 0
829
+ };
830
+ return {
831
+ fallbackMode,
832
+ ...counts,
833
+ attemptRatio: counts.eligibleCount > 0 ? Number((counts.attemptedCount / counts.eligibleCount).toFixed(4)) : null,
834
+ hitRatio: counts.attemptedCount > 0 ? Number((counts.hitCount / counts.attemptedCount).toFixed(4)) : null
835
+ };
836
+ })
837
+ .filter((item) => item.sampleCount > 0)
458
838
  },
459
839
  autoJobStats: [...autoJobs.entries()].map(([operation, durations]) => ({
460
840
  operation,
@@ -34,6 +34,7 @@ const workspaceInboxSource = {
34
34
  actorLabel: "RecallX",
35
35
  toolName: "recallx-system"
36
36
  };
37
+ const DEFAULT_WORKSPACE_SEMANTIC_FALLBACK_MODE = "strict_zero";
37
38
  function normalizeSearchText(value) {
38
39
  return (value ?? "").normalize("NFKC").toLowerCase();
39
40
  }
@@ -52,32 +53,140 @@ function createSearchFieldMatcher(query) {
52
53
  matchTerms: tokens.length ? tokens : [trimmedQuery]
53
54
  };
54
55
  }
55
- function collectMatchedFields(matcher, candidates) {
56
+ function collectSearchFieldSignals(matcher, candidates) {
56
57
  if (!matcher) {
57
- return [];
58
+ return {
59
+ matchedFields: [],
60
+ exactFields: [],
61
+ matchedTermCount: 0,
62
+ totalTermCount: 0
63
+ };
58
64
  }
59
- const matches = new Set();
65
+ const matchedFields = new Set();
66
+ const exactFields = new Set();
67
+ const matchedTerms = new Set();
60
68
  for (const candidate of candidates) {
61
69
  const haystack = normalizeSearchText(candidate.value);
62
70
  if (!haystack) {
63
71
  continue;
64
72
  }
65
- if (haystack.includes(matcher.trimmedQuery) || matcher.matchTerms.some((term) => haystack.includes(term))) {
66
- matches.add(candidate.field);
73
+ const exactMatch = haystack.includes(matcher.trimmedQuery);
74
+ const termMatches = matcher.matchTerms.filter((term) => haystack.includes(term));
75
+ if (!exactMatch && !termMatches.length) {
76
+ continue;
77
+ }
78
+ matchedFields.add(candidate.field);
79
+ if (exactMatch) {
80
+ exactFields.add(candidate.field);
81
+ }
82
+ for (const term of termMatches) {
83
+ matchedTerms.add(term);
84
+ }
85
+ }
86
+ return {
87
+ matchedFields: [...matchedFields],
88
+ exactFields: [...exactFields],
89
+ matchedTermCount: matchedTerms.size,
90
+ totalTermCount: matcher.matchTerms.length
91
+ };
92
+ }
93
+ function classifyNodeLexicalQuality(strategy, signals) {
94
+ if (strategy === "browse" || strategy === "semantic" || !signals.matchedFields.length) {
95
+ return "none";
96
+ }
97
+ if (strategy === "fallback_token") {
98
+ return "weak";
99
+ }
100
+ const strongExactFields = new Set(["title", "summary", "tags"]);
101
+ if (signals.exactFields.some((field) => strongExactFields.has(field))) {
102
+ return "strong";
103
+ }
104
+ const termCoverage = signals.totalTermCount > 0 ? signals.matchedTermCount / signals.totalTermCount : 0;
105
+ if (strategy === "fts" && termCoverage >= 0.6 && signals.matchedFields.some((field) => strongExactFields.has(field))) {
106
+ return "strong";
107
+ }
108
+ return "weak";
109
+ }
110
+ function classifyActivityLexicalQuality(strategy, signals) {
111
+ if (strategy === "browse" || strategy === "semantic" || !signals.matchedFields.length) {
112
+ return "none";
113
+ }
114
+ if (strategy === "fallback_token") {
115
+ return "weak";
116
+ }
117
+ if (signals.exactFields.some((field) => field === "targetNodeTitle" || field === "body" || field === "activityType")) {
118
+ return "strong";
119
+ }
120
+ const termCoverage = signals.totalTermCount > 0 ? signals.matchedTermCount / signals.totalTermCount : 0;
121
+ return strategy === "fts" && termCoverage >= 0.6 ? "strong" : "weak";
122
+ }
123
+ function summarizeLexicalQuality(items) {
124
+ if (items.some((item) => item.lexicalQuality === "strong")) {
125
+ return "strong";
126
+ }
127
+ if (items.some((item) => item.lexicalQuality === "weak")) {
128
+ return "weak";
129
+ }
130
+ return "none";
131
+ }
132
+ function computeWorkspaceResultComposition(input) {
133
+ if (input.nodeCount === 0 && input.activityCount === 0) {
134
+ return "empty";
135
+ }
136
+ if (input.nodeCount > 0 && input.activityCount === 0) {
137
+ return input.semanticUsed ? "semantic_node_only" : "node_only";
138
+ }
139
+ if (input.nodeCount === 0 && input.activityCount > 0) {
140
+ return "activity_only";
141
+ }
142
+ return input.semanticUsed ? "semantic_mixed" : "mixed";
143
+ }
144
+ function mergeLexicalQuality(left, right) {
145
+ if (left === "strong" || right === "strong") {
146
+ return "strong";
147
+ }
148
+ if (left === "weak" || right === "weak") {
149
+ return "weak";
150
+ }
151
+ return "none";
152
+ }
153
+ function mergeNodeSearchItems(primary, secondary) {
154
+ const merged = [...primary];
155
+ const indexById = new Map(primary.map((item, index) => [item.id, index]));
156
+ for (const item of secondary) {
157
+ const existingIndex = indexById.get(item.id);
158
+ if (existingIndex == null) {
159
+ indexById.set(item.id, merged.length);
160
+ merged.push(item);
161
+ continue;
67
162
  }
163
+ const existing = merged[existingIndex];
164
+ merged[existingIndex] = {
165
+ ...existing,
166
+ lexicalQuality: existing.lexicalQuality === "strong" ? "strong" : item.lexicalQuality ?? existing.lexicalQuality,
167
+ matchReason: existing.matchReason && item.matchReason
168
+ ? mergeMatchReasons(existing.matchReason, item.matchReason, existing.matchReason.strategy)
169
+ : existing.matchReason ?? item.matchReason
170
+ };
68
171
  }
69
- return [...matches];
172
+ return merged;
70
173
  }
71
- function buildSearchMatchReason(strategy, matchedFields) {
174
+ function buildSearchMatchReason(strategy, matchedFields, extras = {}) {
72
175
  return {
73
176
  strategy,
74
- matchedFields
177
+ matchedFields,
178
+ ...(extras.strength ? { strength: extras.strength } : {}),
179
+ ...(extras.termCoverage != null ? { termCoverage: extras.termCoverage } : {})
75
180
  };
76
181
  }
77
182
  function mergeMatchReasons(left, right, strategy) {
78
183
  return {
79
184
  strategy,
80
- matchedFields: Array.from(new Set([...(left?.matchedFields ?? []), ...(right?.matchedFields ?? [])]))
185
+ matchedFields: Array.from(new Set([...(left?.matchedFields ?? []), ...(right?.matchedFields ?? [])])),
186
+ strength: left?.strength ?? right?.strength,
187
+ termCoverage: typeof left?.termCoverage === "number" || typeof right?.termCoverage === "number"
188
+ ? Math.max(left?.termCoverage ?? 0, right?.termCoverage ?? 0)
189
+ : null
81
190
  };
82
191
  }
83
192
  function computeWorkspaceRankBonus(index, total) {
@@ -87,8 +196,18 @@ function computeWorkspaceRankBonus(index, total) {
87
196
  return Math.max(0, Math.round(((total - index) / total) * 24));
88
197
  }
89
198
  function computeWorkspaceSmartScore(input) {
199
+ const matchBonus = input.matchReason?.strategy === "semantic"
200
+ ? 3
201
+ : input.lexicalQuality === "strong"
202
+ ? 10
203
+ : input.lexicalQuality === "weak"
204
+ ? input.matchReason?.strategy === "fallback_token"
205
+ ? 1
206
+ : 4
207
+ : 0;
90
208
  return (computeWorkspaceRankBonus(input.index, input.total) +
91
209
  computeWorkspaceRecencyBonusFromAge(input.nowMs - new Date(input.timestamp).getTime(), input.resultType) +
210
+ matchBonus +
92
211
  (input.resultType === "activity" ? 4 : 0) -
93
212
  (input.contested ? 20 : 0));
94
213
  }
@@ -132,6 +251,9 @@ function readNumberSetting(settings, key, fallback) {
132
251
  const value = settings[key];
133
252
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
134
253
  }
254
+ function normalizeWorkspaceSemanticFallbackMode(value) {
255
+ return value === "no_strong_node_hit" ? "no_strong_node_hit" : DEFAULT_WORKSPACE_SEMANTIC_FALLBACK_MODE;
256
+ }
135
257
  function normalizeSemanticIndexBackend(value) {
136
258
  return value === "sqlite-vec" ? "sqlite-vec" : "sqlite";
137
259
  }
@@ -171,7 +293,9 @@ function readSemanticIndexSettingSnapshot(settings, runtime) {
171
293
  indexBackend: resolveActiveSemanticIndexBackend(configuredIndexBackend, runtime.sqliteVecLoaded),
172
294
  extensionStatus: resolveSemanticExtensionStatus(configuredIndexBackend, runtime.sqliteVecLoaded),
173
295
  extensionLoadError: configuredIndexBackend === "sqlite-vec" && !runtime.sqliteVecLoaded ? runtime.sqliteVecLoadError : null,
174
- chunkEnabled: readBooleanSetting(settings, "search.semantic.chunk.enabled", false)
296
+ chunkEnabled: readBooleanSetting(settings, "search.semantic.chunk.enabled", false),
297
+ workspaceFallbackEnabled: readBooleanSetting(settings, "search.semantic.workspaceFallback.enabled", false),
298
+ workspaceFallbackMode: normalizeWorkspaceSemanticFallbackMode(settings["search.semantic.workspaceFallback.mode"])
175
299
  };
176
300
  }
177
301
  function shouldReindexForSemanticConfigChange(previous, next) {
@@ -601,7 +725,8 @@ export class RecallXRepository {
601
725
  "search.semantic.indexBackend",
602
726
  "search.semantic.chunk.enabled",
603
727
  "search.semantic.chunk.aggregation",
604
- "search.semantic.workspaceFallback.enabled"
728
+ "search.semantic.workspaceFallback.enabled",
729
+ "search.semantic.workspaceFallback.mode"
605
730
  ]);
606
731
  return {
607
732
  ...readSemanticIndexSettingSnapshot(settings, {
@@ -609,7 +734,8 @@ export class RecallXRepository {
609
734
  sqliteVecLoadError: this.sqliteVecRuntime.loadError
610
735
  }),
611
736
  chunkAggregation: normalizeSemanticChunkAggregation(settings["search.semantic.chunk.aggregation"]),
612
- workspaceFallbackEnabled: readBooleanSetting(settings, "search.semantic.workspaceFallback.enabled", false)
737
+ workspaceFallbackEnabled: readBooleanSetting(settings, "search.semantic.workspaceFallback.enabled", false),
738
+ workspaceFallbackMode: normalizeWorkspaceSemanticFallbackMode(settings["search.semantic.workspaceFallback.mode"])
613
739
  };
614
740
  }
615
741
  getSemanticAugmentationSettings() {
@@ -1010,6 +1136,8 @@ export class RecallXRepository {
1010
1136
  "search.semantic.model",
1011
1137
  "search.semantic.indexBackend",
1012
1138
  "search.semantic.chunk.enabled",
1139
+ "search.semantic.workspaceFallback.enabled",
1140
+ "search.semantic.workspaceFallback.mode",
1013
1141
  "search.semantic.last_backfill_at"
1014
1142
  ]);
1015
1143
  const semanticSettings = readSemanticIndexSettingSnapshot(settings, {
@@ -1031,6 +1159,8 @@ export class RecallXRepository {
1031
1159
  }
1032
1160
  return {
1033
1161
  ...semanticStatusSettings,
1162
+ workspaceFallbackEnabled: readBooleanSetting(settings, "search.semantic.workspaceFallback.enabled", false),
1163
+ workspaceFallbackMode: normalizeWorkspaceSemanticFallbackMode(settings["search.semantic.workspaceFallback.mode"]),
1034
1164
  lastBackfillAt: readStringSetting(settings, "search.semantic.last_backfill_at"),
1035
1165
  counts
1036
1166
  };
@@ -1303,6 +1433,7 @@ export class RecallXRepository {
1303
1433
  const result = this.searchNodesWithFts(input);
1304
1434
  appendCurrentTelemetryDetails({
1305
1435
  ftsFallback: false,
1436
+ lexicalQuality: summarizeLexicalQuality(result.items),
1306
1437
  resultCount: result.items.length,
1307
1438
  totalCount: result.total
1308
1439
  });
@@ -1312,6 +1443,7 @@ export class RecallXRepository {
1312
1443
  const fallbackResult = this.searchNodesWithLike(input);
1313
1444
  appendCurrentTelemetryDetails({
1314
1445
  ftsFallback: true,
1446
+ lexicalQuality: summarizeLexicalQuality(fallbackResult.items),
1315
1447
  resultCount: fallbackResult.items.length,
1316
1448
  totalCount: fallbackResult.total
1317
1449
  });
@@ -1321,6 +1453,7 @@ export class RecallXRepository {
1321
1453
  const result = this.searchNodesWithLike(input);
1322
1454
  appendCurrentTelemetryDetails({
1323
1455
  ftsFallback: false,
1456
+ lexicalQuality: summarizeLexicalQuality(result.items),
1324
1457
  resultCount: result.items.length,
1325
1458
  totalCount: result.total
1326
1459
  });
@@ -1332,6 +1465,7 @@ export class RecallXRepository {
1332
1465
  const result = this.searchActivitiesWithFts(input);
1333
1466
  appendCurrentTelemetryDetails({
1334
1467
  ftsFallback: false,
1468
+ lexicalQuality: summarizeLexicalQuality(result.items),
1335
1469
  resultCount: result.items.length,
1336
1470
  totalCount: result.total
1337
1471
  });
@@ -1341,6 +1475,7 @@ export class RecallXRepository {
1341
1475
  const fallbackResult = this.searchActivitiesWithLike(input);
1342
1476
  appendCurrentTelemetryDetails({
1343
1477
  ftsFallback: true,
1478
+ lexicalQuality: summarizeLexicalQuality(fallbackResult.items),
1344
1479
  resultCount: fallbackResult.items.length,
1345
1480
  totalCount: fallbackResult.total
1346
1481
  });
@@ -1350,6 +1485,7 @@ export class RecallXRepository {
1350
1485
  const result = this.searchActivitiesWithLike(input);
1351
1486
  appendCurrentTelemetryDetails({
1352
1487
  ftsFallback: false,
1488
+ lexicalQuality: summarizeLexicalQuality(result.items),
1353
1489
  resultCount: result.items.length,
1354
1490
  totalCount: result.total
1355
1491
  });
@@ -1465,6 +1601,8 @@ export class RecallXRepository {
1465
1601
  const resolvedActivityResults = fallbackTokens.length >= 2 && includeActivities
1466
1602
  ? this.searchWorkspaceActivityFallback(fallbackTokens, input.activityFilters ?? {}, requestedWindow)
1467
1603
  : activityResults;
1604
+ const bestNodeLexicalQuality = summarizeLexicalQuality(resolvedNodeResults.items);
1605
+ const bestActivityLexicalQuality = summarizeLexicalQuality(resolvedActivityResults.items);
1468
1606
  const merged = this.mergeWorkspaceSearchResults(resolvedNodeResults.items, resolvedActivityResults.items, input.sort);
1469
1607
  const deterministicResult = {
1470
1608
  total: fallbackTokens.length >= 2
@@ -1477,6 +1615,7 @@ export class RecallXRepository {
1477
1615
  semanticFallbackEligible: false,
1478
1616
  semanticFallbackAttempted: false,
1479
1617
  semanticFallbackUsed: false,
1618
+ semanticFallbackMode: includeNodes && semanticSettings.workspaceFallbackEnabled ? semanticSettings.workspaceFallbackMode : null,
1480
1619
  semanticFallbackCandidateCount: 0,
1481
1620
  semanticFallbackResultCount: 0,
1482
1621
  semanticFallbackBackend: null,
@@ -1485,16 +1624,31 @@ export class RecallXRepository {
1485
1624
  semanticFallbackQueryLengthBucket: queryPresent ? bucketSemanticQueryLength(normalizedQuery.length) : null
1486
1625
  };
1487
1626
  const appendWorkspaceSearchTelemetry = (result) => {
1627
+ const nodeItems = result.items.flatMap((item) => item.resultType === "node" && item.node ? [item.node] : []);
1628
+ const activityItems = result.items.flatMap((item) => item.resultType === "activity" && item.activity ? [item.activity] : []);
1488
1629
  appendCurrentTelemetryDetails({
1630
+ searchHit: result.items.length > 0,
1489
1631
  candidateCount: requestedWindow,
1490
1632
  nodeCandidateCount: resolvedNodeResults.items.length,
1491
1633
  activityCandidateCount: resolvedActivityResults.items.length,
1634
+ nodeResultCount: nodeItems.length,
1635
+ activityResultCount: activityItems.length,
1636
+ bestNodeLexicalQuality,
1637
+ bestActivityLexicalQuality,
1638
+ lexicalNodeHit: bestNodeLexicalQuality !== "none",
1639
+ strongNodeLexicalHit: bestNodeLexicalQuality === "strong",
1640
+ resultComposition: computeWorkspaceResultComposition({
1641
+ nodeCount: nodeItems.length,
1642
+ activityCount: activityItems.length,
1643
+ semanticUsed: telemetry.semanticFallbackUsed
1644
+ }),
1492
1645
  resultCount: result.items.length,
1493
1646
  totalCount: result.total,
1494
1647
  fallbackTokenCount: fallbackTokens.length,
1495
1648
  semanticFallbackEligible: telemetry.semanticFallbackEligible,
1496
1649
  semanticFallbackAttempted: telemetry.semanticFallbackAttempted,
1497
1650
  semanticFallbackUsed: telemetry.semanticFallbackUsed,
1651
+ semanticFallbackMode: telemetry.semanticFallbackMode ?? undefined,
1498
1652
  semanticFallbackCandidateCount: telemetry.semanticFallbackCandidateCount,
1499
1653
  semanticFallbackResultCount: telemetry.semanticFallbackResultCount,
1500
1654
  semanticFallbackBackend: telemetry.semanticFallbackBackend,
@@ -1502,13 +1656,18 @@ export class RecallXRepository {
1502
1656
  semanticFallbackSkippedReason: telemetry.semanticFallbackSkippedReason
1503
1657
  });
1504
1658
  };
1659
+ const strictZeroFallbackBlocked = resolvedNodeResults.total + resolvedActivityResults.total > 0;
1660
+ const noStrongNodeFallbackBlocked = bestNodeLexicalQuality === "strong";
1661
+ const semanticFallbackBlockedByMode = semanticSettings.workspaceFallbackMode === "strict_zero"
1662
+ ? strictZeroFallbackBlocked
1663
+ : noStrongNodeFallbackBlocked;
1505
1664
  const shouldAttemptSemanticFallback = includeNodes &&
1506
1665
  semanticSettings.workspaceFallbackEnabled &&
1507
1666
  queryPresent &&
1508
1667
  normalizedQuery.length >= 6 &&
1509
- deterministicResult.total === 0 &&
1510
1668
  semanticSettings.enabled &&
1511
- Boolean(semanticSettings.provider && semanticSettings.model);
1669
+ Boolean(semanticSettings.provider && semanticSettings.model) &&
1670
+ !semanticFallbackBlockedByMode;
1512
1671
  if (!includeNodes) {
1513
1672
  telemetry.semanticFallbackSkippedReason = "nodes_scope_disabled";
1514
1673
  }
@@ -1521,15 +1680,18 @@ export class RecallXRepository {
1521
1680
  else if (!semanticSettings.workspaceFallbackEnabled) {
1522
1681
  telemetry.semanticFallbackSkippedReason = "workspace_fallback_disabled";
1523
1682
  }
1524
- else if (deterministicResult.total > 0) {
1525
- telemetry.semanticFallbackSkippedReason = "deterministic_results_present";
1526
- }
1527
1683
  else if (!semanticSettings.enabled) {
1528
1684
  telemetry.semanticFallbackSkippedReason = "semantic_disabled";
1529
1685
  }
1530
1686
  else if (!semanticSettings.provider || !semanticSettings.model) {
1531
1687
  telemetry.semanticFallbackSkippedReason = "semantic_provider_unconfigured";
1532
1688
  }
1689
+ else if (semanticSettings.workspaceFallbackMode === "strict_zero" && strictZeroFallbackBlocked) {
1690
+ telemetry.semanticFallbackSkippedReason = "strict_zero_results_present";
1691
+ }
1692
+ else if (semanticSettings.workspaceFallbackMode === "no_strong_node_hit" && noStrongNodeFallbackBlocked) {
1693
+ telemetry.semanticFallbackSkippedReason = "strong_node_lexical_present";
1694
+ }
1533
1695
  if (shouldAttemptSemanticFallback) {
1534
1696
  const candidateNodeIds = this.listWorkspaceSemanticFallbackCandidateNodeIds(input.nodeFilters ?? {}, semanticSettings, 200);
1535
1697
  telemetry.semanticFallbackEligible = true;
@@ -1541,8 +1703,7 @@ export class RecallXRepository {
1541
1703
  else {
1542
1704
  telemetry.semanticFallbackAttempted = true;
1543
1705
  const runSemanticFallback = async () => {
1544
- const semanticMatches = await this.rankSemanticCandidates(normalizedQuery, candidateNodeIds);
1545
- const items = this.buildWorkspaceSemanticFallbackNodeItems(candidateNodeIds, semanticMatches, this.getSemanticAugmentationSettings()).map((node) => ({ resultType: "node", node }));
1706
+ const items = this.buildWorkspaceSemanticFallbackNodeItems(candidateNodeIds, await this.rankSemanticCandidates(normalizedQuery, candidateNodeIds), this.getSemanticAugmentationSettings());
1546
1707
  return {
1547
1708
  items,
1548
1709
  resultCount: items.length
@@ -1554,15 +1715,18 @@ export class RecallXRepository {
1554
1715
  semanticFallbackCandidateCount: candidateNodeIds.length,
1555
1716
  semanticFallbackBackend: semanticSettings.indexBackend,
1556
1717
  semanticFallbackConfiguredBackend: semanticSettings.configuredIndexBackend,
1718
+ semanticFallbackMode: telemetry.semanticFallbackMode ?? undefined,
1557
1719
  semanticFallbackQueryLengthBucket: telemetry.semanticFallbackQueryLengthBucket
1558
1720
  }, runSemanticFallback)
1559
1721
  : await runSemanticFallback();
1560
1722
  telemetry.semanticFallbackResultCount = semanticResult.resultCount;
1561
1723
  if (semanticResult.resultCount > 0) {
1562
1724
  telemetry.semanticFallbackUsed = true;
1725
+ const mergedNodeItems = mergeNodeSearchItems(semanticResult.items, resolvedNodeResults.items);
1726
+ const mergedSemanticItems = this.mergeWorkspaceSearchResults(mergedNodeItems, resolvedActivityResults.items, input.sort);
1563
1727
  const semanticWorkspaceResult = {
1564
- total: semanticResult.resultCount,
1565
- items: semanticResult.items
1728
+ total: mergedNodeItems.length + (includeActivities ? resolvedActivityResults.total : 0),
1729
+ items: mergedSemanticItems.slice(input.offset, input.offset + input.limit)
1566
1730
  };
1567
1731
  appendWorkspaceSearchTelemetry(semanticWorkspaceResult);
1568
1732
  return {
@@ -1638,6 +1802,8 @@ export class RecallXRepository {
1638
1802
  timestamp: node.updatedAt,
1639
1803
  resultType: "node",
1640
1804
  contested: node.status === "contested",
1805
+ matchReason: node.matchReason,
1806
+ lexicalQuality: node.lexicalQuality,
1641
1807
  nowMs
1642
1808
  })
1643
1809
  : 0
@@ -1656,6 +1822,8 @@ export class RecallXRepository {
1656
1822
  timestamp: activity.createdAt,
1657
1823
  resultType: "activity",
1658
1824
  contested: activity.targetNodeStatus === "contested",
1825
+ matchReason: activity.matchReason,
1826
+ lexicalQuality: activity.lexicalQuality,
1659
1827
  nowMs
1660
1828
  })
1661
1829
  : 0
@@ -1673,6 +1841,24 @@ export class RecallXRepository {
1673
1841
  }
1674
1842
  return merged.map(({ index: _index, total: _total, timestamp: _timestamp, contested: _contested, smartScore: _smartScore, ...item }) => item);
1675
1843
  }
1844
+ mergeNodeSearchResults(primary, secondary) {
1845
+ const merged = new Map();
1846
+ for (const item of [...primary, ...secondary]) {
1847
+ const current = merged.get(item.id);
1848
+ if (!current) {
1849
+ merged.set(item.id, item);
1850
+ continue;
1851
+ }
1852
+ merged.set(item.id, {
1853
+ ...current,
1854
+ matchReason: current.matchReason?.strategy === "semantic" && item.matchReason
1855
+ ? item.matchReason
1856
+ : current.matchReason ?? item.matchReason,
1857
+ lexicalQuality: mergeLexicalQuality(current.lexicalQuality, item.lexicalQuality)
1858
+ });
1859
+ }
1860
+ return Array.from(merged.values());
1861
+ }
1676
1862
  searchNodesWithFts(input) {
1677
1863
  const where = [];
1678
1864
  const values = [];
@@ -1851,23 +2037,31 @@ export class RecallXRepository {
1851
2037
  LIMIT ? OFFSET ?`)
1852
2038
  .all(...whereValues, ...params.orderValues, effectiveLimit, effectiveOffset);
1853
2039
  const matcher = params.strategy === "browse" ? null : createSearchFieldMatcher(params.input.query);
1854
- const items = rows.map((row) => ({
1855
- id: String(row.id),
1856
- targetNodeId: String(row.target_node_id),
1857
- targetNodeTitle: row.target_title ? String(row.target_title) : null,
1858
- targetNodeType: row.target_type ? row.target_type : null,
1859
- targetNodeStatus: row.target_status ? row.target_status : null,
1860
- activityType: row.activity_type,
1861
- body: row.body ? String(row.body) : null,
1862
- sourceLabel: row.source_label ? String(row.source_label) : null,
1863
- createdAt: String(row.created_at),
1864
- matchReason: buildSearchMatchReason(params.strategy, collectMatchedFields(matcher, [
2040
+ const items = rows.map((row) => {
2041
+ const signals = collectSearchFieldSignals(matcher, [
1865
2042
  { field: "body", value: row.body ? String(row.body) : null },
1866
2043
  { field: "targetNodeTitle", value: row.target_title ? String(row.target_title) : null },
1867
2044
  { field: "activityType", value: row.activity_type ? String(row.activity_type) : null },
1868
2045
  { field: "sourceLabel", value: row.source_label ? String(row.source_label) : null }
1869
- ]))
1870
- }));
2046
+ ]);
2047
+ const lexicalQuality = classifyActivityLexicalQuality(params.strategy, signals);
2048
+ return {
2049
+ id: String(row.id),
2050
+ targetNodeId: String(row.target_node_id),
2051
+ targetNodeTitle: row.target_title ? String(row.target_title) : null,
2052
+ targetNodeType: row.target_type ? row.target_type : null,
2053
+ targetNodeStatus: row.target_status ? row.target_status : null,
2054
+ activityType: row.activity_type,
2055
+ body: row.body ? String(row.body) : null,
2056
+ sourceLabel: row.source_label ? String(row.source_label) : null,
2057
+ createdAt: String(row.created_at),
2058
+ lexicalQuality,
2059
+ matchReason: buildSearchMatchReason(params.strategy, signals.matchedFields, {
2060
+ strength: lexicalQuality === "none" ? undefined : lexicalQuality,
2061
+ termCoverage: signals.totalTermCount > 0 ? Number((signals.matchedTermCount / signals.totalTermCount).toFixed(4)) : null
2062
+ })
2063
+ };
2064
+ });
1871
2065
  const rankedItems = useSearchFeedbackBoost ? this.applyActivitySearchFeedbackBoost(items) : items;
1872
2066
  const cappedItems = this.capActivityResultsPerTarget(rankedItems);
1873
2067
  return {
@@ -1914,6 +2108,14 @@ export class RecallXRepository {
1914
2108
  const matcher = strategy === "browse" ? null : createSearchFieldMatcher(query);
1915
2109
  const items = rows.map((row) => {
1916
2110
  const tags = parseJson(row.tags_json, []);
2111
+ const signals = collectSearchFieldSignals(matcher, [
2112
+ { field: "title", value: row.title ? String(row.title) : null },
2113
+ { field: "summary", value: row.summary ? String(row.summary) : null },
2114
+ { field: "body", value: row.body ? String(row.body) : null },
2115
+ { field: "tags", value: tags.join(" ") },
2116
+ { field: "sourceLabel", value: row.source_label ? String(row.source_label) : null }
2117
+ ]);
2118
+ const lexicalQuality = classifyNodeLexicalQuality(strategy, signals);
1917
2119
  return {
1918
2120
  id: String(row.id),
1919
2121
  type: row.type,
@@ -1924,13 +2126,11 @@ export class RecallXRepository {
1924
2126
  sourceLabel: row.source_label ? String(row.source_label) : null,
1925
2127
  updatedAt: String(row.updated_at),
1926
2128
  tags,
1927
- matchReason: buildSearchMatchReason(strategy, collectMatchedFields(matcher, [
1928
- { field: "title", value: row.title ? String(row.title) : null },
1929
- { field: "summary", value: row.summary ? String(row.summary) : null },
1930
- { field: "body", value: row.body ? String(row.body) : null },
1931
- { field: "tags", value: tags.join(" ") },
1932
- { field: "sourceLabel", value: row.source_label ? String(row.source_label) : null }
1933
- ]))
2129
+ lexicalQuality,
2130
+ matchReason: buildSearchMatchReason(strategy, signals.matchedFields, {
2131
+ strength: lexicalQuality === "none" ? undefined : lexicalQuality,
2132
+ termCoverage: signals.totalTermCount > 0 ? Number((signals.matchedTermCount / signals.totalTermCount).toFixed(4)) : null
2133
+ })
1934
2134
  };
1935
2135
  });
1936
2136
  const rankedItems = useSearchFeedbackBoost ? this.applySearchFeedbackBoost(items) : items;
@@ -76,6 +76,7 @@ export class WorkspaceSessionManager {
76
76
  "search.semantic.chunk.enabled": false,
77
77
  "search.semantic.chunk.aggregation": "max",
78
78
  "search.semantic.workspaceFallback.enabled": false,
79
+ "search.semantic.workspaceFallback.mode": "strict_zero",
79
80
  "search.semantic.augmentation.minSimilarity": 0.2,
80
81
  "search.semantic.augmentation.maxBonus": 18,
81
82
  "search.semantic.last_backfill_at": null,
@@ -1 +1 @@
1
- export const RECALLX_VERSION = "1.0.0";
1
+ export const RECALLX_VERSION = "1.0.2";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "recallx-headless",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Headless RecallX runtime with API, CLI, and MCP entrypoint.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,7 @@
24
24
  "zod": "^3.25.76"
25
25
  },
26
26
  "engines": {
27
- "node": ">=20"
27
+ "node": ">=22.13.0"
28
28
  },
29
29
  "keywords": [
30
30
  "recallx",