recallx 1.0.7 → 1.0.8

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jazpiper
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -225,3 +225,7 @@ For launcher paths, environment variables, and editor-specific setup, see `docs/
225
225
  - `docs/mcp.md` for MCP bridge setup
226
226
  - `docs/workflows.md` for common usage flows
227
227
  - `docs/schema.md` for storage and data model details
228
+
229
+ ## License
230
+
231
+ MIT. See `LICENSE`.
package/app/server/app.js CHANGED
@@ -9,7 +9,7 @@ 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
11
  import { appendCurrentTelemetryDetails, createObservabilityWriter, summarizePayloadShape } from "./observability.js";
12
- import { buildSemanticCandidateBonusMap, buildCandidateRelationBonusMap, buildContextBundle, buildNeighborhoodItems, buildTargetRelatedRetrievalItems, bundleAsMarkdown, computeRankCandidateScore, shouldUseSemanticCandidateAugmentation } from "./retrieval.js";
12
+ import { buildSemanticCandidateBonusMap, buildCandidateRelationBonusMap, buildContextBundle, buildNeighborhoodItems, buildNeighborhoodItemsBatch, buildTargetRelatedRetrievalItems, bundleAsMarkdown, computeRankCandidateScore, selectSemanticCandidateIds, shouldUseSemanticCandidateAugmentation } from "./retrieval.js";
13
13
  import { buildProjectGraph } from "./project-graph.js";
14
14
  import { createId, isPathWithinRoot } from "./utils.js";
15
15
  const relationTypeSet = new Set(relationTypes);
@@ -1779,19 +1779,17 @@ export function createRecallXApp(params) {
1779
1779
  }, (span) => {
1780
1780
  const repository = currentRepository();
1781
1781
  const nodeId = readRequestParam(request.params.id);
1782
- const result = buildNeighborhoodItems(repository, nodeId, {
1782
+ const neighborhoodOptions = {
1783
1783
  relationTypes: types,
1784
1784
  includeInferred,
1785
1785
  maxInferred
1786
- });
1786
+ };
1787
+ const result = buildNeighborhoodItems(repository, nodeId, neighborhoodOptions);
1787
1788
  const expanded = depth === 2
1788
1789
  ? (() => {
1789
1790
  const seen = new Set(result.map((item) => `${item.edge.relationId}:${item.node.id}:1`));
1790
- const secondHop = result.flatMap((item) => buildNeighborhoodItems(repository, item.node.id, {
1791
- relationTypes: types,
1792
- includeInferred,
1793
- maxInferred
1794
- })
1791
+ const secondHopByNodeId = buildNeighborhoodItemsBatch(repository, result.map((item) => item.node.id), neighborhoodOptions);
1792
+ const secondHop = result.flatMap((item) => (secondHopByNodeId.get(item.node.id) ?? [])
1795
1793
  .filter((nested) => nested.node.id !== nodeId)
1796
1794
  .map((nested) => ({
1797
1795
  ...nested,
@@ -1814,7 +1812,8 @@ export function createRecallXApp(params) {
1814
1812
  })()
1815
1813
  : result;
1816
1814
  span.addDetails({
1817
- resultCount: expanded.length
1815
+ resultCount: expanded.length,
1816
+ firstHopCount: result.length
1818
1817
  });
1819
1818
  return expanded;
1820
1819
  });
@@ -2154,8 +2153,9 @@ export function createRecallXApp(params) {
2154
2153
  });
2155
2154
  const semanticAugmentation = repository.getSemanticAugmentationSettings();
2156
2155
  const semanticEnabled = shouldUseSemanticCandidateAugmentation(query, candidates);
2156
+ const semanticCandidateIds = selectSemanticCandidateIds(query, candidates);
2157
2157
  const semanticBonuses = semanticEnabled
2158
- ? buildSemanticCandidateBonusMap(await repository.rankSemanticCandidates(query, candidateNodeIds), semanticAugmentation)
2158
+ ? buildSemanticCandidateBonusMap(await repository.rankSemanticCandidates(query, semanticCandidateIds), semanticAugmentation)
2159
2159
  : new Map();
2160
2160
  const result = candidates
2161
2161
  .map((node) => {
@@ -2179,7 +2179,9 @@ export function createRecallXApp(params) {
2179
2179
  .sort((left, right) => right.score - left.score);
2180
2180
  span.addDetails({
2181
2181
  resultCount: result.length,
2182
- semanticUsed: semanticEnabled
2182
+ semanticUsed: semanticEnabled,
2183
+ semanticCandidateCount: candidates.length,
2184
+ semanticRankedCandidateCount: semanticCandidateIds.length
2183
2185
  });
2184
2186
  return result;
2185
2187
  });
@@ -14,6 +14,9 @@ function normalizeText(value) {
14
14
  function normalizeTags(tags) {
15
15
  return Array.from(new Set(tags.map((tag) => normalizeText(tag)).filter(Boolean)));
16
16
  }
17
+ function normalizeTexts(values) {
18
+ return values.map(normalizeText).filter(Boolean);
19
+ }
17
20
  function escapeRegExp(value) {
18
21
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
19
22
  }
@@ -33,6 +36,10 @@ function mentionsNode(haystacks, candidate) {
33
36
  : false;
34
37
  return { idMention, titleMention };
35
38
  }
39
+ function hasCheapMention(normalizedHaystacks, candidate) {
40
+ const normalizedTitle = normalizeText(candidate.title);
41
+ return normalizedHaystacks.some((haystack) => haystack.includes(candidate.id.toLowerCase()) || (normalizedTitle.length >= 5 && haystack.includes(normalizedTitle)));
42
+ }
36
43
  function sortPair(left, right) {
37
44
  return left.localeCompare(right) <= 0 ? [left, right] : [right, left];
38
45
  }
@@ -154,36 +161,42 @@ function collectGeneratedCandidates(repository, target, trigger) {
154
161
  projectIds: new Set(projectMembershipsByNodeId.get(target.id) ?? []),
155
162
  artifactKeys: artifactKeysByNodeId.get(target.id) ?? { exactPaths: [], baseNames: [] }
156
163
  };
164
+ const normalizedTargetTexts = normalizeTexts([target.title, target.body, target.summary]);
157
165
  const activityBodies = trigger === "activity-append" || trigger === "reindex"
158
166
  ? repository
159
167
  .listNodeActivities(target.id, MAX_ACTIVITY_BODIES)
160
168
  .map((activity) => activity.body ?? "")
161
169
  .filter(Boolean)
162
170
  : [];
171
+ const normalizedActivityBodies = activityBodies.map(normalizeText).filter(Boolean);
163
172
  return candidates.flatMap((candidate) => {
164
173
  const generated = [];
174
+ const candidateProjectIds = projectMembershipsByNodeId.get(candidate.id) ?? [];
175
+ const candidateArtifacts = artifactKeysByNodeId.get(candidate.id) ?? { exactPaths: [], baseNames: [] };
165
176
  const tagOverlapCandidate = buildTagOverlapCandidate(target, candidate, targetContext.normalizedTags);
166
177
  if (tagOverlapCandidate) {
167
178
  generated.push(tagOverlapCandidate);
168
179
  }
169
- const bodyReferenceCandidate = buildBodyReferenceCandidate(target, candidate);
180
+ const projectMembershipCandidate = buildProjectMembershipCandidate(target, candidate, candidateProjectIds, targetContext.projectIds);
181
+ if (projectMembershipCandidate) {
182
+ generated.push(projectMembershipCandidate);
183
+ }
184
+ const sharedArtifactCandidate = buildSharedArtifactCandidate(target, candidate, candidateArtifacts, targetContext.artifactKeys);
185
+ if (sharedArtifactCandidate) {
186
+ generated.push(sharedArtifactCandidate);
187
+ }
188
+ const candidateTexts = normalizeTexts([candidate.title, candidate.body, candidate.summary]);
189
+ const shouldCheckBodyReference = hasCheapMention(normalizedTargetTexts, candidate) || hasCheapMention(candidateTexts, target);
190
+ const bodyReferenceCandidate = shouldCheckBodyReference ? buildBodyReferenceCandidate(target, candidate) : null;
170
191
  if (bodyReferenceCandidate) {
171
192
  generated.push(bodyReferenceCandidate);
172
193
  }
173
- if (activityBodies.length) {
194
+ if (normalizedActivityBodies.length && hasCheapMention(normalizedActivityBodies, candidate)) {
174
195
  const activityReferenceCandidate = buildActivityReferenceCandidate(target, candidate, activityBodies);
175
196
  if (activityReferenceCandidate) {
176
197
  generated.push(activityReferenceCandidate);
177
198
  }
178
199
  }
179
- const projectMembershipCandidate = buildProjectMembershipCandidate(target, candidate, projectMembershipsByNodeId.get(candidate.id) ?? [], targetContext.projectIds);
180
- if (projectMembershipCandidate) {
181
- generated.push(projectMembershipCandidate);
182
- }
183
- const sharedArtifactCandidate = buildSharedArtifactCandidate(target, candidate, artifactKeysByNodeId.get(candidate.id) ?? { exactPaths: [], baseNames: [] }, targetContext.artifactKeys);
184
- if (sharedArtifactCandidate) {
185
- generated.push(sharedArtifactCandidate);
186
- }
187
200
  return generated;
188
201
  });
189
202
  }
@@ -1,6 +1,8 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
- import { appendFile, mkdir, readFile, readdir, unlink } from "node:fs/promises";
2
+ import { createReadStream } from "node:fs";
3
+ import { appendFile, mkdir, readdir, unlink } from "node:fs/promises";
3
4
  import path from "node:path";
5
+ import { createInterface } from "node:readline";
4
6
  import { createId } from "./utils.js";
5
7
  const telemetryStorage = new AsyncLocalStorage();
6
8
  function nowIso() {
@@ -45,6 +47,10 @@ function roundDuration(value) {
45
47
  function dateStamp(value) {
46
48
  return value.slice(0, 10);
47
49
  }
50
+ function telemetryLogDate(entry) {
51
+ const match = /^telemetry-(\d{4}-\d{2}-\d{2})\.ndjson$/.exec(entry);
52
+ return match ? match[1] : null;
53
+ }
48
54
  function normalizeRetentionDays(value) {
49
55
  return Math.max(1, Math.trunc(value || 14));
50
56
  }
@@ -341,24 +347,41 @@ export class ObservabilityWriter {
341
347
  }
342
348
  const files = entries
343
349
  .filter((entry) => /^telemetry-\d{4}-\d{2}-\d{2}\.ndjson$/.test(entry))
350
+ .filter((entry) => {
351
+ const stamp = telemetryLogDate(entry);
352
+ return stamp !== null && stamp >= dateStamp(new Date(sinceMs).toISOString());
353
+ })
344
354
  .sort();
345
355
  const events = [];
346
356
  for (const file of files) {
347
357
  const filePath = path.join(logsDir, file);
348
- const content = await readFile(filePath, "utf8").catch(() => "");
349
- for (const line of content.split("\n")) {
350
- const event = parseJsonLine(line);
351
- if (!event) {
352
- continue;
353
- }
354
- const eventMs = Date.parse(event.ts);
355
- if (!Number.isFinite(eventMs) || eventMs < sinceMs) {
356
- continue;
357
- }
358
- if (options.surface && options.surface !== "all" && event.surface !== options.surface) {
359
- continue;
358
+ const stream = createReadStream(filePath, { encoding: "utf8" });
359
+ const lines = createInterface({
360
+ input: stream,
361
+ crlfDelay: Number.POSITIVE_INFINITY
362
+ });
363
+ try {
364
+ for await (const line of lines) {
365
+ const event = parseJsonLine(line);
366
+ if (!event) {
367
+ continue;
368
+ }
369
+ const eventMs = Date.parse(event.ts);
370
+ if (!Number.isFinite(eventMs) || eventMs < sinceMs) {
371
+ continue;
372
+ }
373
+ if (options.surface && options.surface !== "all" && event.surface !== options.surface) {
374
+ continue;
375
+ }
376
+ events.push(event);
360
377
  }
361
- events.push(event);
378
+ }
379
+ catch {
380
+ // Ignore individual file read failures so observability endpoints stay resilient.
381
+ }
382
+ finally {
383
+ lines.close();
384
+ stream.destroy();
362
385
  }
363
386
  }
364
387
  return {
@@ -18,6 +18,7 @@ const boostedRelationRankWeights = {
18
18
  };
19
19
  const semanticCandidateMinSimilarity = 0.2;
20
20
  const semanticCandidateMaxBonus = 18;
21
+ const maxSemanticPrefilterCandidates = 256;
21
22
  function resolveSemanticAugmentationSettings(settings) {
22
23
  return {
23
24
  minSimilarity: typeof settings?.minSimilarity === "number" && Number.isFinite(settings.minSimilarity)
@@ -38,7 +39,15 @@ function prioritizeItems(items, preset, maxItems, bonuses) {
38
39
  .map(({ item }) => item);
39
40
  return weighted.slice(0, maxItems);
40
41
  }
41
- function buildNeighborhoodResult(repository, nodeId, options) {
42
+ function normalizeRetrievalText(value) {
43
+ return (value ?? "").toLowerCase().replace(/\s+/g, " ").trim();
44
+ }
45
+ function tokenizeRetrievalText(value) {
46
+ return normalizeRetrievalText(value)
47
+ .split(/[^a-z0-9]+/g)
48
+ .filter((token) => token.length >= 3);
49
+ }
50
+ function listNeighborhoodCandidates(repository, nodeId, options) {
42
51
  const canonicalItems = repository.listRelatedNodes(nodeId, 1, options?.relationTypes).map(({ node, relation }) => ({
43
52
  node,
44
53
  edge: {
@@ -94,6 +103,13 @@ function buildNeighborhoodResult(repository, nodeId, options) {
94
103
  });
95
104
  })()
96
105
  : [];
106
+ return {
107
+ canonicalItems,
108
+ inferredItems
109
+ };
110
+ }
111
+ function buildNeighborhoodResult(repository, nodeId, options) {
112
+ const { canonicalItems, inferredItems } = listNeighborhoodCandidates(repository, nodeId, options);
97
113
  const usageSummaries = repository.getRelationUsageSummaries([...canonicalItems, ...inferredItems].map((item) => item.edge.relationId));
98
114
  const rankedCanonical = rankNeighborhoodItems(canonicalItems, usageSummaries, neighborhoodRetrievalRankWeights);
99
115
  const rankedInferred = options?.includeInferred && options.maxInferred
@@ -104,6 +120,27 @@ function buildNeighborhoodResult(repository, nodeId, options) {
104
120
  usageSummaries
105
121
  };
106
122
  }
123
+ export function buildNeighborhoodItemsBatch(repository, nodeIds, options) {
124
+ const uniqueNodeIds = Array.from(new Set(nodeIds.filter(Boolean)));
125
+ const rawByNodeId = new Map();
126
+ const relationIds = [];
127
+ for (const nodeId of uniqueNodeIds) {
128
+ const raw = listNeighborhoodCandidates(repository, nodeId, options);
129
+ rawByNodeId.set(nodeId, raw);
130
+ relationIds.push(...raw.canonicalItems.map((item) => item.edge.relationId));
131
+ relationIds.push(...raw.inferredItems.map((item) => item.edge.relationId));
132
+ }
133
+ const usageSummaries = repository.getRelationUsageSummaries(relationIds);
134
+ const results = new Map();
135
+ for (const [nodeId, raw] of rawByNodeId.entries()) {
136
+ const rankedCanonical = rankNeighborhoodItems(raw.canonicalItems, usageSummaries, neighborhoodRetrievalRankWeights);
137
+ const rankedInferred = options?.includeInferred && options.maxInferred
138
+ ? rankNeighborhoodItems(raw.inferredItems, usageSummaries, neighborhoodRetrievalRankWeights, options.maxInferred)
139
+ : [];
140
+ results.set(nodeId, [...rankedCanonical, ...rankedInferred]);
141
+ }
142
+ return results;
143
+ }
107
144
  function matchesSearchResultFilters(item, filters) {
108
145
  const typeMatches = !filters.types?.length || filters.types.includes(item.type);
109
146
  const statusMatches = !filters.status?.length || filters.status.includes(item.status);
@@ -214,6 +251,45 @@ export function buildSemanticCandidateBonusMap(semanticMatches, settings) {
214
251
  ];
215
252
  }));
216
253
  }
254
+ function scoreSemanticPrefilterCandidate(normalizedQuery, queryTokens, candidate) {
255
+ const normalizedTitle = normalizeRetrievalText(candidate.title);
256
+ const normalizedSummary = normalizeRetrievalText(candidate.summary);
257
+ let score = 0;
258
+ if (normalizedTitle.includes(normalizedQuery)) {
259
+ score += 12;
260
+ }
261
+ if (normalizedSummary.includes(normalizedQuery)) {
262
+ score += 6;
263
+ }
264
+ for (const token of queryTokens) {
265
+ if (normalizedTitle.includes(token)) {
266
+ score += 2;
267
+ }
268
+ if (normalizedSummary.includes(token)) {
269
+ score += 1;
270
+ }
271
+ }
272
+ return score;
273
+ }
274
+ export function selectSemanticCandidateIds(query, candidates, maxCandidates = maxSemanticPrefilterCandidates) {
275
+ if (candidates.length <= maxCandidates) {
276
+ return candidates.map((candidate) => candidate.id);
277
+ }
278
+ const normalizedQuery = normalizeRetrievalText(query);
279
+ if (!normalizedQuery) {
280
+ return candidates.slice(0, maxCandidates).map((candidate) => candidate.id);
281
+ }
282
+ const queryTokens = Array.from(new Set(tokenizeRetrievalText(query)));
283
+ return candidates
284
+ .map((candidate) => ({
285
+ id: candidate.id,
286
+ score: scoreSemanticPrefilterCandidate(normalizedQuery, queryTokens, candidate),
287
+ updatedAt: candidate.updatedAt
288
+ }))
289
+ .sort((left, right) => right.score - left.score || right.updatedAt.localeCompare(left.updatedAt))
290
+ .slice(0, maxCandidates)
291
+ .map((candidate) => candidate.id);
292
+ }
217
293
  function computeBundleRelationBoost(item, summary) {
218
294
  return computeRelationRetrievalRank(item.edge, summary, {
219
295
  canonicalBase: 120,
@@ -397,15 +473,19 @@ export async function buildContextBundle(repository, input) {
397
473
  const candidateItems = [targetItem, ...relatedItems, ...decisions, ...openQuestions];
398
474
  const dedupedItems = Array.from(new Map(candidateItems.map((item) => [item.id, item])).values());
399
475
  const semanticQuery = [target.title, target.summary ?? target.body].filter(Boolean).join("\n");
400
- const semanticBonuses = shouldUseSemanticCandidateAugmentation(semanticQuery, dedupedItems.filter((item) => item.id !== target.id))
401
- ? buildSemanticCandidateBonusMap(await repository.rankSemanticCandidates(semanticQuery, dedupedItems.filter((item) => item.id !== target.id).map((item) => item.id)), repository.getSemanticAugmentationSettings())
476
+ const semanticCandidates = dedupedItems.filter((item) => item.id !== target.id);
477
+ const semanticCandidateIds = selectSemanticCandidateIds(semanticQuery, semanticCandidates);
478
+ const semanticBonuses = shouldUseSemanticCandidateAugmentation(semanticQuery, semanticCandidates)
479
+ ? buildSemanticCandidateBonusMap(await repository.rankSemanticCandidates(semanticQuery, semanticCandidateIds), repository.getSemanticAugmentationSettings())
402
480
  : new Map();
403
481
  appendCurrentTelemetryDetails({
404
482
  neighborhoodCount: neighborhood.length,
405
483
  relatedCandidateCount: relatedItems.length,
406
484
  decisionCount: decisions.length,
407
485
  openQuestionCount: openQuestions.length,
408
- semanticUsed: semanticBonuses.size > 0
486
+ semanticUsed: semanticBonuses.size > 0,
487
+ semanticCandidateCount: semanticCandidates.length,
488
+ semanticRankedCandidateCount: semanticCandidateIds.length
409
489
  });
410
490
  const combinedBonuses = new Map();
411
491
  for (const item of dedupedItems) {
@@ -1 +1 @@
1
- export const RECALLX_VERSION = "1.0.7";
1
+ export const RECALLX_VERSION = "1.0.8";