recallx 1.0.8 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -59,12 +59,14 @@ function collectSearchFieldSignals(matcher, candidates) {
59
59
  matchedFields: [],
60
60
  exactFields: [],
61
61
  matchedTermCount: 0,
62
+ matchedTermCounts: {},
62
63
  totalTermCount: 0
63
64
  };
64
65
  }
65
66
  const matchedFields = new Set();
66
67
  const exactFields = new Set();
67
68
  const matchedTerms = new Set();
69
+ const matchedTermCounts = {};
68
70
  for (const candidate of candidates) {
69
71
  const haystack = normalizeSearchText(candidate.value);
70
72
  if (!haystack) {
@@ -76,6 +78,7 @@ function collectSearchFieldSignals(matcher, candidates) {
76
78
  continue;
77
79
  }
78
80
  matchedFields.add(candidate.field);
81
+ matchedTermCounts[candidate.field] = termMatches.length;
79
82
  if (exactMatch) {
80
83
  exactFields.add(candidate.field);
81
84
  }
@@ -87,6 +90,7 @@ function collectSearchFieldSignals(matcher, candidates) {
87
90
  matchedFields: [...matchedFields],
88
91
  exactFields: [...exactFields],
89
92
  matchedTermCount: matchedTerms.size,
93
+ matchedTermCounts,
90
94
  totalTermCount: matcher.matchTerms.length
91
95
  };
92
96
  }
@@ -94,14 +98,18 @@ function classifyNodeLexicalQuality(strategy, signals) {
94
98
  if (strategy === "browse" || strategy === "semantic" || !signals.matchedFields.length) {
95
99
  return "none";
96
100
  }
97
- if (strategy === "fallback_token") {
98
- return "weak";
99
- }
100
- const strongExactFields = new Set(["title", "summary", "tags"]);
101
+ const strongExactFields = new Set(["title", "summary", "tags", "body"]);
101
102
  if (signals.exactFields.some((field) => strongExactFields.has(field))) {
102
103
  return "strong";
103
104
  }
104
105
  const termCoverage = signals.totalTermCount > 0 ? signals.matchedTermCount / signals.totalTermCount : 0;
106
+ const titleCoverage = signals.totalTermCount > 0 ? (signals.matchedTermCounts.title ?? 0) / signals.totalTermCount : 0;
107
+ if (strategy === "fallback_token") {
108
+ return titleCoverage >= 0.5 ? "strong" : "weak";
109
+ }
110
+ if (strategy === "fts" && titleCoverage >= 0.5) {
111
+ return "strong";
112
+ }
105
113
  if (strategy === "fts" && termCoverage >= 0.6 && signals.matchedFields.some((field) => strongExactFields.has(field))) {
106
114
  return "strong";
107
115
  }
@@ -1282,6 +1290,12 @@ export class RecallXRepository {
1282
1290
  tags: parseJson(row.tags_json, [])
1283
1291
  }));
1284
1292
  }
1293
+ listAllNodes() {
1294
+ const rows = this.db
1295
+ .prepare(`SELECT * FROM nodes ORDER BY updated_at DESC, id DESC`)
1296
+ .all();
1297
+ return rows.map(mapNode);
1298
+ }
1285
1299
  listActiveNodesByType(type, limit = 20) {
1286
1300
  const rows = this.db
1287
1301
  .prepare(`SELECT *
@@ -1586,37 +1600,60 @@ export class RecallXRepository {
1586
1600
  const queryPresent = Boolean(input.query.trim());
1587
1601
  const searchSort = input.sort === "smart" ? (queryPresent ? "relevance" : "updated_at") : input.sort;
1588
1602
  const normalizedQuery = input.query.trim();
1603
+ const runSearchStage = async (operation, details, callback) => {
1604
+ if (!options.runSearchStageSpan) {
1605
+ return await callback();
1606
+ }
1607
+ return await options.runSearchStageSpan(operation, details, callback);
1608
+ };
1589
1609
  const nodeResults = includeNodes
1590
- ? this.searchNodes({
1610
+ ? await runSearchStage("workspace.search.nodes.deterministic", {
1611
+ queryPresent,
1612
+ limit: requestedWindow,
1613
+ sort: searchSort
1614
+ }, () => this.searchNodes({
1591
1615
  query: input.query,
1592
1616
  filters: input.nodeFilters ?? {},
1593
1617
  limit: requestedWindow,
1594
1618
  offset: 0,
1595
1619
  sort: searchSort
1596
- })
1620
+ }))
1597
1621
  : { items: [], total: 0 };
1598
1622
  const activityResults = includeActivities
1599
- ? this.searchActivities({
1623
+ ? await runSearchStage("workspace.search.activities.deterministic", {
1624
+ queryPresent,
1625
+ limit: requestedWindow,
1626
+ sort: searchSort
1627
+ }, () => this.searchActivities({
1600
1628
  query: input.query,
1601
1629
  filters: input.activityFilters ?? {},
1602
1630
  limit: requestedWindow,
1603
1631
  offset: 0,
1604
1632
  sort: searchSort
1605
- })
1633
+ }))
1606
1634
  : { items: [], total: 0 };
1607
1635
  const fallbackTriggered = queryPresent && nodeResults.total + activityResults.total === 0;
1608
1636
  const fallbackTokens = fallbackTriggered ? tokenizeSearchQuery(input.query, SEARCH_FALLBACK_TOKEN_LIMIT) : [];
1609
1637
  const resolvedNodeResults = fallbackTokens.length >= 2 && includeNodes
1610
- ? this.searchWorkspaceNodeFallback(fallbackTokens, input.nodeFilters ?? {}, requestedWindow)
1638
+ ? await runSearchStage("workspace.search.nodes.fallback_token", {
1639
+ fallbackTokenCount: fallbackTokens.length,
1640
+ limit: requestedWindow,
1641
+ queryPresent
1642
+ }, () => this.searchWorkspaceNodeFallback(fallbackTokens, input.nodeFilters ?? {}, requestedWindow))
1611
1643
  : nodeResults;
1612
1644
  const resolvedActivityResults = fallbackTokens.length >= 2 && includeActivities
1613
- ? this.searchWorkspaceActivityFallback(fallbackTokens, input.activityFilters ?? {}, requestedWindow)
1645
+ ? await runSearchStage("workspace.search.activities.fallback_token", {
1646
+ fallbackTokenCount: fallbackTokens.length,
1647
+ limit: requestedWindow,
1648
+ queryPresent
1649
+ }, () => this.searchWorkspaceActivityFallback(fallbackTokens, input.activityFilters ?? {}, requestedWindow))
1614
1650
  : activityResults;
1615
1651
  const bestNodeLexicalQuality = summarizeLexicalQuality(resolvedNodeResults.items);
1616
1652
  const bestActivityLexicalQuality = summarizeLexicalQuality(resolvedActivityResults.items);
1617
1653
  const merged = this.mergeWorkspaceSearchResults(resolvedNodeResults.items, resolvedActivityResults.items, input.sort);
1654
+ const fallbackTokenUsed = fallbackTokens.length >= 2;
1618
1655
  const deterministicResult = {
1619
- total: fallbackTokens.length >= 2
1656
+ total: fallbackTokenUsed
1620
1657
  ? merged.length
1621
1658
  : resolvedNodeResults.total + resolvedActivityResults.total,
1622
1659
  items: merged.slice(input.offset, input.offset + input.limit)
@@ -1639,15 +1676,23 @@ export class RecallXRepository {
1639
1676
  const activityItems = result.items.flatMap((item) => item.resultType === "activity" && item.activity ? [item.activity] : []);
1640
1677
  appendCurrentTelemetryDetails({
1641
1678
  searchHit: result.items.length > 0,
1679
+ requestedWindow,
1642
1680
  candidateCount: requestedWindow,
1681
+ rawNodeTotalCount: nodeResults.total,
1682
+ rawActivityTotalCount: activityResults.total,
1683
+ resolvedNodeTotalCount: resolvedNodeResults.total,
1684
+ resolvedActivityTotalCount: resolvedActivityResults.total,
1643
1685
  nodeCandidateCount: resolvedNodeResults.items.length,
1644
1686
  activityCandidateCount: resolvedActivityResults.items.length,
1687
+ mergedCandidateCount: merged.length,
1645
1688
  nodeResultCount: nodeItems.length,
1646
1689
  activityResultCount: activityItems.length,
1647
1690
  bestNodeLexicalQuality,
1648
1691
  bestActivityLexicalQuality,
1649
1692
  lexicalNodeHit: bestNodeLexicalQuality !== "none",
1650
1693
  strongNodeLexicalHit: bestNodeLexicalQuality === "strong",
1694
+ queryFallbackTriggered: fallbackTriggered,
1695
+ queryFallbackUsed: fallbackTokenUsed,
1651
1696
  resultComposition: computeWorkspaceResultComposition({
1652
1697
  nodeCount: nodeItems.length,
1653
1698
  activityCount: activityItems.length,
@@ -1655,7 +1700,8 @@ export class RecallXRepository {
1655
1700
  }),
1656
1701
  resultCount: result.items.length,
1657
1702
  totalCount: result.total,
1658
- fallbackTokenCount: fallbackTokens.length,
1703
+ queryFallbackTermCount: fallbackTokens.length,
1704
+ totalCountStrategy: fallbackTokenUsed ? "merged_length" : "source_total_sum",
1659
1705
  semanticFallbackEligible: telemetry.semanticFallbackEligible,
1660
1706
  semanticFallbackAttempted: telemetry.semanticFallbackAttempted,
1661
1707
  semanticFallbackUsed: telemetry.semanticFallbackUsed,
@@ -2039,6 +2085,7 @@ export class RecallXRepository {
2039
2085
  a.body,
2040
2086
  a.source_label,
2041
2087
  a.created_at,
2088
+ a.metadata_json,
2042
2089
  n.title AS target_title,
2043
2090
  n.type AS target_type,
2044
2091
  n.status AS target_status
@@ -2066,6 +2113,7 @@ export class RecallXRepository {
2066
2113
  body: row.body ? String(row.body) : null,
2067
2114
  sourceLabel: row.source_label ? String(row.source_label) : null,
2068
2115
  createdAt: String(row.created_at),
2116
+ metadata: parseJson(row.metadata_json, {}),
2069
2117
  lexicalQuality,
2070
2118
  matchReason: buildSearchMatchReason(params.strategy, signals.matchedFields, {
2071
2119
  strength: lexicalQuality === "none" ? undefined : lexicalQuality,
@@ -2312,25 +2360,59 @@ export class RecallXRepository {
2312
2360
  : "";
2313
2361
  const rows = this.db
2314
2362
  .prepare(`SELECT
2315
- r.*,
2316
- CASE WHEN r.from_node_id = ? THEN r.to_node_id ELSE r.from_node_id END AS related_id
2363
+ r.id,
2364
+ r.from_node_id,
2365
+ r.to_node_id,
2366
+ r.relation_type,
2367
+ r.status,
2368
+ r.created_by,
2369
+ r.source_type,
2370
+ r.source_label,
2371
+ r.created_at,
2372
+ r.metadata_json,
2373
+ n.id AS node_id,
2374
+ n.type AS node_type,
2375
+ n.status AS node_status,
2376
+ n.canonicality AS node_canonicality,
2377
+ n.visibility AS node_visibility,
2378
+ n.title AS node_title,
2379
+ n.body AS node_body,
2380
+ n.summary AS node_summary,
2381
+ n.created_by AS node_created_by,
2382
+ n.source_type AS node_source_type,
2383
+ n.source_label AS node_source_label,
2384
+ n.created_at AS node_created_at,
2385
+ n.updated_at AS node_updated_at,
2386
+ n.tags_json AS node_tags_json,
2387
+ n.metadata_json AS node_metadata_json
2317
2388
  FROM relations r
2389
+ JOIN nodes n
2390
+ ON n.id = CASE WHEN r.from_node_id = ? THEN r.to_node_id ELSE r.from_node_id END
2318
2391
  WHERE (r.from_node_id = ? OR r.to_node_id = ?)
2319
2392
  AND r.status != 'archived'
2320
2393
  ${relationWhere}
2321
2394
  ORDER BY r.created_at DESC`)
2322
2395
  .all(nodeId, nodeId, nodeId, ...(relationFilter ?? []));
2323
- const relatedNodes = this.getNodesByIds(rows.map((row) => String(row.related_id)));
2324
- return rows.flatMap((row) => {
2325
- const node = relatedNodes.get(String(row.related_id));
2326
- if (!node) {
2327
- return [];
2396
+ return rows.map((row) => ({
2397
+ relation: mapRelation(row),
2398
+ node: {
2399
+ id: String(row.node_id),
2400
+ type: row.node_type,
2401
+ status: row.node_status,
2402
+ canonicality: row.node_canonicality,
2403
+ visibility: String(row.node_visibility),
2404
+ title: row.node_title ? String(row.node_title) : null,
2405
+ body: row.node_body ? String(row.node_body) : null,
2406
+ summary: row.node_summary ? String(row.node_summary) : null,
2407
+ createdBy: row.node_created_by ? String(row.node_created_by) : null,
2408
+ sourceType: row.node_source_type ? String(row.node_source_type) : null,
2409
+ sourceLabel: row.node_source_label ? String(row.node_source_label) : null,
2410
+ createdAt: String(row.node_created_at),
2411
+ updatedAt: String(row.node_updated_at),
2412
+ tags: parseJson(row.node_tags_json, []),
2413
+ metadata: parseJson(row.node_metadata_json, {})
2328
2414
  }
2329
- return [{
2330
- relation: mapRelation(row),
2331
- node
2332
- }];
2333
- });
2415
+ }));
2334
2416
  }
2335
2417
  listProjectMemberNodes(projectId, limit) {
2336
2418
  const rows = this.db
@@ -2375,6 +2457,12 @@ export class RecallXRepository {
2375
2457
  .all(...uniqueIds, ...uniqueIds);
2376
2458
  return rows.map(mapRelation);
2377
2459
  }
2460
+ listAllRelations() {
2461
+ const rows = this.db
2462
+ .prepare(`SELECT * FROM relations ORDER BY created_at ASC, id ASC`)
2463
+ .all();
2464
+ return rows.map(mapRelation);
2465
+ }
2378
2466
  createRelation(input) {
2379
2467
  const now = nowIso();
2380
2468
  const id = createId("rel");
@@ -2678,6 +2766,60 @@ export class RecallXRepository {
2678
2766
  .all(entityType, entityId, limit);
2679
2767
  return rows.map(mapGovernanceEvent);
2680
2768
  }
2769
+ listRecentGovernanceEvents(options) {
2770
+ const limit = options?.limit ?? 12;
2771
+ const entityTypes = options?.entityTypes?.length ? options.entityTypes : undefined;
2772
+ const actions = options?.actions?.length ? options.actions : undefined;
2773
+ const where = [`json_extract(ge.metadata_json, '$.manualAction') IS NOT NULL`];
2774
+ const params = [];
2775
+ if (entityTypes?.length) {
2776
+ where.push(`ge.entity_type IN (${entityTypes.map(() => "?").join(", ")})`);
2777
+ params.push(...entityTypes);
2778
+ }
2779
+ if (actions?.length) {
2780
+ where.push(`json_extract(ge.metadata_json, '$.manualAction') IN (${actions.map(() => "?").join(", ")})`);
2781
+ params.push(...actions);
2782
+ }
2783
+ const rows = this.db
2784
+ .prepare(`SELECT
2785
+ ge.*,
2786
+ json_extract(ge.metadata_json, '$.manualAction') AS manual_action,
2787
+ CASE
2788
+ WHEN ge.entity_type = 'node' THEN n.title
2789
+ ELSE COALESCE(fn.title, r.from_node_id) || ' ' || r.relation_type || ' ' || COALESCE(tn.title, r.to_node_id)
2790
+ END AS display_title,
2791
+ CASE
2792
+ WHEN ge.entity_type = 'node' THEN n.type
2793
+ ELSE r.status
2794
+ END AS display_subtitle,
2795
+ CASE WHEN ge.entity_type = 'node' THEN ge.entity_id ELSE NULL END AS node_id,
2796
+ CASE WHEN ge.entity_type = 'relation' THEN r.from_node_id ELSE NULL END AS from_node_id,
2797
+ CASE WHEN ge.entity_type = 'relation' THEN r.to_node_id ELSE NULL END AS to_node_id,
2798
+ CASE WHEN ge.entity_type = 'relation' THEN r.relation_type ELSE NULL END AS relation_type
2799
+ FROM governance_events ge
2800
+ LEFT JOIN nodes n
2801
+ ON ge.entity_type = 'node'
2802
+ AND ge.entity_id = n.id
2803
+ LEFT JOIN relations r
2804
+ ON ge.entity_type = 'relation'
2805
+ AND ge.entity_id = r.id
2806
+ LEFT JOIN nodes fn ON fn.id = r.from_node_id
2807
+ LEFT JOIN nodes tn ON tn.id = r.to_node_id
2808
+ WHERE ${where.join(" AND ")}
2809
+ ORDER BY ge.created_at DESC
2810
+ LIMIT ?`)
2811
+ .all(...params, limit);
2812
+ return rows.map((row) => ({
2813
+ ...mapGovernanceEvent(row),
2814
+ action: row.manual_action ? String(row.manual_action) : null,
2815
+ title: row.display_title ? String(row.display_title) : null,
2816
+ subtitle: row.display_subtitle ? String(row.display_subtitle) : null,
2817
+ nodeId: row.node_id ? String(row.node_id) : null,
2818
+ fromNodeId: row.from_node_id ? String(row.from_node_id) : null,
2819
+ toNodeId: row.to_node_id ? String(row.to_node_id) : null,
2820
+ relationType: row.relation_type ? String(row.relation_type) : null
2821
+ }));
2822
+ }
2681
2823
  upsertGovernanceState(params) {
2682
2824
  const now = params.lastEvaluatedAt ?? nowIso();
2683
2825
  const existing = params.previousState === undefined
@@ -2986,6 +3128,12 @@ export class RecallXRepository {
2986
3128
  .all(...uniqueIds, limit);
2987
3129
  return rows.map(mapActivity);
2988
3130
  }
3131
+ listAllActivities() {
3132
+ const rows = this.db
3133
+ .prepare(`SELECT * FROM activities ORDER BY created_at ASC, id ASC`)
3134
+ .all();
3135
+ return rows.map(mapActivity);
3136
+ }
2989
3137
  appendActivity(input) {
2990
3138
  const id = createId("act");
2991
3139
  const now = nowIso();
@@ -3068,6 +3216,12 @@ export class RecallXRepository {
3068
3216
  .all(nodeId);
3069
3217
  return rows.map(mapArtifact);
3070
3218
  }
3219
+ listAllArtifacts() {
3220
+ const rows = this.db
3221
+ .prepare(`SELECT * FROM artifacts ORDER BY created_at DESC, id DESC`)
3222
+ .all();
3223
+ return rows.map(mapArtifact);
3224
+ }
3071
3225
  getArtifact(id) {
3072
3226
  const row = this.db.prepare(`SELECT * FROM artifacts WHERE id = ?`).get(id);
3073
3227
  return mapArtifact(assertPresent(row, `Artifact ${id} not found`));
@@ -0,0 +1,10 @@
1
+ export function isReadonlySqliteWriteError(error) {
2
+ if (!error || typeof error !== "object") {
3
+ return false;
4
+ }
5
+ const candidate = error;
6
+ const code = typeof candidate.code === "string" ? candidate.code : "";
7
+ const errstr = typeof candidate.errstr === "string" ? candidate.errstr.toLowerCase() : "";
8
+ const message = typeof candidate.message === "string" ? candidate.message.toLowerCase() : "";
9
+ return code === "ERR_SQLITE_ERROR" && (errstr.includes("readonly database") || message.includes("readonly database"));
10
+ }
@@ -0,0 +1,161 @@
1
+ export const DEFAULT_IMPORT_OPTIONS = {
2
+ normalizeTitleWhitespace: true,
3
+ trimBodyWhitespace: false,
4
+ duplicateMode: "warn",
5
+ };
6
+ export function resolveImportOptions(options) {
7
+ return {
8
+ normalizeTitleWhitespace: typeof options?.normalizeTitleWhitespace === "boolean"
9
+ ? options.normalizeTitleWhitespace
10
+ : DEFAULT_IMPORT_OPTIONS.normalizeTitleWhitespace,
11
+ trimBodyWhitespace: typeof options?.trimBodyWhitespace === "boolean"
12
+ ? options.trimBodyWhitespace
13
+ : DEFAULT_IMPORT_OPTIONS.trimBodyWhitespace,
14
+ duplicateMode: options?.duplicateMode === "skip_exact" || options?.duplicateMode === "warn"
15
+ ? options.duplicateMode
16
+ : DEFAULT_IMPORT_OPTIONS.duplicateMode,
17
+ };
18
+ }
19
+ export function normalizeTitle(value, options) {
20
+ const trimmed = value.trim();
21
+ if (!options.normalizeTitleWhitespace) {
22
+ return trimmed || "Imported node";
23
+ }
24
+ return trimmed.replace(/\s+/g, " ").trim() || "Imported node";
25
+ }
26
+ export function normalizeBody(value, options) {
27
+ const unix = value.replace(/\r\n/g, "\n");
28
+ if (!options.trimBodyWhitespace) {
29
+ return unix;
30
+ }
31
+ return unix.replace(/[ \t]+$/gm, "").replace(/\s+$/u, "");
32
+ }
33
+ export function buildTitleKey(title, options) {
34
+ return normalizeTitle(title, options).toLowerCase();
35
+ }
36
+ export function buildExactKey(type, title, body, options) {
37
+ return `${type}::${buildTitleKey(title, options)}::${normalizeBody(body, options)}`;
38
+ }
39
+ export function buildDuplicateIndex(existingNodes, options) {
40
+ const exact = new Map();
41
+ const title = new Map();
42
+ for (const node of existingNodes) {
43
+ const nodeType = node.type;
44
+ const nodeTitle = node.title ?? node.id;
45
+ const nodeBody = node.body ?? "";
46
+ const exactKey = buildExactKey(nodeType, nodeTitle, nodeBody, options);
47
+ const titleKey = buildTitleKey(nodeTitle, options);
48
+ exact.set(exactKey, [...(exact.get(exactKey) ?? []), node]);
49
+ title.set(titleKey, [...(title.get(titleKey) ?? []), node]);
50
+ }
51
+ return { exact, title };
52
+ }
53
+ export function detectDuplicateMatch(params) {
54
+ const exactKey = buildExactKey(params.node.type, params.node.title, params.node.body, params.options);
55
+ const titleKey = buildTitleKey(params.node.title, params.options);
56
+ const workspaceExact = params.existing.exact.get(exactKey)?.[0];
57
+ if (workspaceExact) {
58
+ return {
59
+ title: params.node.title,
60
+ sourcePath: params.node.sourcePath,
61
+ matchType: "exact",
62
+ existingNodeId: workspaceExact.id,
63
+ existingNodeTitle: workspaceExact.title,
64
+ existingSource: "workspace",
65
+ };
66
+ }
67
+ const batchExact = params.seen.exact.get(exactKey);
68
+ if (batchExact) {
69
+ return {
70
+ title: params.node.title,
71
+ sourcePath: params.node.sourcePath,
72
+ matchType: "exact",
73
+ existingNodeId: null,
74
+ existingNodeTitle: batchExact.title,
75
+ existingSource: "batch",
76
+ };
77
+ }
78
+ const workspaceTitle = params.existing.title.get(titleKey)?.[0];
79
+ if (workspaceTitle) {
80
+ return {
81
+ title: params.node.title,
82
+ sourcePath: params.node.sourcePath,
83
+ matchType: "title",
84
+ existingNodeId: workspaceTitle.id,
85
+ existingNodeTitle: workspaceTitle.title,
86
+ existingSource: "workspace",
87
+ };
88
+ }
89
+ const batchTitle = params.seen.title.get(titleKey);
90
+ if (batchTitle) {
91
+ return {
92
+ title: params.node.title,
93
+ sourcePath: params.node.sourcePath,
94
+ matchType: "title",
95
+ existingNodeId: null,
96
+ existingNodeTitle: batchTitle.title,
97
+ existingSource: "batch",
98
+ };
99
+ }
100
+ return null;
101
+ }
102
+ export function rememberSeenNode(node, options, seen) {
103
+ const exactKey = buildExactKey(node.type, node.title, node.body, options);
104
+ const titleKey = buildTitleKey(node.title, options);
105
+ if (!seen.exact.has(exactKey)) {
106
+ seen.exact.set(exactKey, node);
107
+ }
108
+ if (!seen.title.has(titleKey)) {
109
+ seen.title.set(titleKey, node);
110
+ }
111
+ }
112
+ export function buildPreviewFromPlan(plan) {
113
+ const duplicateItems = plan.nodes
114
+ .filter((node) => node.duplicate !== null)
115
+ .map((node) => node.duplicate);
116
+ const exactDuplicateCandidates = duplicateItems.filter((item) => item.matchType === "exact").length;
117
+ const skippedOriginalIds = new Set(plan.options.duplicateMode === "skip_exact"
118
+ ? plan.nodes.filter((node) => node.duplicate?.matchType === "exact" && node.originalId).map((node) => node.originalId)
119
+ : []);
120
+ const skippedNodes = plan.options.duplicateMode === "skip_exact"
121
+ ? plan.nodes.filter((node) => node.duplicate?.matchType === "exact").length
122
+ : 0;
123
+ const skippedRelations = plan.options.duplicateMode === "skip_exact"
124
+ ? plan.relations.filter((relation) => (relation.fromOriginalId && skippedOriginalIds.has(relation.fromOriginalId)) ||
125
+ (relation.toOriginalId && skippedOriginalIds.has(relation.toOriginalId))).length
126
+ : 0;
127
+ const skippedActivities = plan.options.duplicateMode === "skip_exact"
128
+ ? plan.activities.filter((activity) => activity.targetOriginalId && skippedOriginalIds.has(activity.targetOriginalId)).length
129
+ : 0;
130
+ return {
131
+ format: plan.format,
132
+ label: plan.label,
133
+ sourcePath: plan.sourcePath,
134
+ createdAt: plan.createdAt,
135
+ options: plan.options,
136
+ nodesDetected: plan.nodes.length,
137
+ relationsDetected: plan.relations.length,
138
+ activitiesDetected: plan.activities.length,
139
+ duplicateCandidates: duplicateItems.length,
140
+ exactDuplicateCandidates,
141
+ nodesReady: plan.nodes.length - skippedNodes,
142
+ relationsReady: plan.relations.length - skippedRelations,
143
+ activitiesReady: plan.activities.length - skippedActivities,
144
+ skippedNodes,
145
+ skippedRelations,
146
+ skippedActivities,
147
+ warnings: [
148
+ ...plan.warnings,
149
+ ...(duplicateItems.length
150
+ ? [`Detected ${duplicateItems.length} likely duplicate node(s) in the current import preview.`]
151
+ : []),
152
+ ],
153
+ sampleItems: plan.nodes.slice(0, 5).map((node) => ({
154
+ title: node.title,
155
+ type: node.type,
156
+ sourcePath: node.sourcePath,
157
+ duplicateKind: node.duplicate?.matchType ?? null,
158
+ })),
159
+ duplicateItems,
160
+ };
161
+ }