recallx 1.1.0 → 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.
@@ -1290,6 +1290,12 @@ export class RecallXRepository {
1290
1290
  tags: parseJson(row.tags_json, [])
1291
1291
  }));
1292
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
+ }
1293
1299
  listActiveNodesByType(type, limit = 20) {
1294
1300
  const rows = this.db
1295
1301
  .prepare(`SELECT *
@@ -1594,37 +1600,60 @@ export class RecallXRepository {
1594
1600
  const queryPresent = Boolean(input.query.trim());
1595
1601
  const searchSort = input.sort === "smart" ? (queryPresent ? "relevance" : "updated_at") : input.sort;
1596
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
+ };
1597
1609
  const nodeResults = includeNodes
1598
- ? this.searchNodes({
1610
+ ? await runSearchStage("workspace.search.nodes.deterministic", {
1611
+ queryPresent,
1612
+ limit: requestedWindow,
1613
+ sort: searchSort
1614
+ }, () => this.searchNodes({
1599
1615
  query: input.query,
1600
1616
  filters: input.nodeFilters ?? {},
1601
1617
  limit: requestedWindow,
1602
1618
  offset: 0,
1603
1619
  sort: searchSort
1604
- })
1620
+ }))
1605
1621
  : { items: [], total: 0 };
1606
1622
  const activityResults = includeActivities
1607
- ? this.searchActivities({
1623
+ ? await runSearchStage("workspace.search.activities.deterministic", {
1624
+ queryPresent,
1625
+ limit: requestedWindow,
1626
+ sort: searchSort
1627
+ }, () => this.searchActivities({
1608
1628
  query: input.query,
1609
1629
  filters: input.activityFilters ?? {},
1610
1630
  limit: requestedWindow,
1611
1631
  offset: 0,
1612
1632
  sort: searchSort
1613
- })
1633
+ }))
1614
1634
  : { items: [], total: 0 };
1615
1635
  const fallbackTriggered = queryPresent && nodeResults.total + activityResults.total === 0;
1616
1636
  const fallbackTokens = fallbackTriggered ? tokenizeSearchQuery(input.query, SEARCH_FALLBACK_TOKEN_LIMIT) : [];
1617
1637
  const resolvedNodeResults = fallbackTokens.length >= 2 && includeNodes
1618
- ? 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))
1619
1643
  : nodeResults;
1620
1644
  const resolvedActivityResults = fallbackTokens.length >= 2 && includeActivities
1621
- ? 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))
1622
1650
  : activityResults;
1623
1651
  const bestNodeLexicalQuality = summarizeLexicalQuality(resolvedNodeResults.items);
1624
1652
  const bestActivityLexicalQuality = summarizeLexicalQuality(resolvedActivityResults.items);
1625
1653
  const merged = this.mergeWorkspaceSearchResults(resolvedNodeResults.items, resolvedActivityResults.items, input.sort);
1654
+ const fallbackTokenUsed = fallbackTokens.length >= 2;
1626
1655
  const deterministicResult = {
1627
- total: fallbackTokens.length >= 2
1656
+ total: fallbackTokenUsed
1628
1657
  ? merged.length
1629
1658
  : resolvedNodeResults.total + resolvedActivityResults.total,
1630
1659
  items: merged.slice(input.offset, input.offset + input.limit)
@@ -1647,15 +1676,23 @@ export class RecallXRepository {
1647
1676
  const activityItems = result.items.flatMap((item) => item.resultType === "activity" && item.activity ? [item.activity] : []);
1648
1677
  appendCurrentTelemetryDetails({
1649
1678
  searchHit: result.items.length > 0,
1679
+ requestedWindow,
1650
1680
  candidateCount: requestedWindow,
1681
+ rawNodeTotalCount: nodeResults.total,
1682
+ rawActivityTotalCount: activityResults.total,
1683
+ resolvedNodeTotalCount: resolvedNodeResults.total,
1684
+ resolvedActivityTotalCount: resolvedActivityResults.total,
1651
1685
  nodeCandidateCount: resolvedNodeResults.items.length,
1652
1686
  activityCandidateCount: resolvedActivityResults.items.length,
1687
+ mergedCandidateCount: merged.length,
1653
1688
  nodeResultCount: nodeItems.length,
1654
1689
  activityResultCount: activityItems.length,
1655
1690
  bestNodeLexicalQuality,
1656
1691
  bestActivityLexicalQuality,
1657
1692
  lexicalNodeHit: bestNodeLexicalQuality !== "none",
1658
1693
  strongNodeLexicalHit: bestNodeLexicalQuality === "strong",
1694
+ queryFallbackTriggered: fallbackTriggered,
1695
+ queryFallbackUsed: fallbackTokenUsed,
1659
1696
  resultComposition: computeWorkspaceResultComposition({
1660
1697
  nodeCount: nodeItems.length,
1661
1698
  activityCount: activityItems.length,
@@ -1663,7 +1700,8 @@ export class RecallXRepository {
1663
1700
  }),
1664
1701
  resultCount: result.items.length,
1665
1702
  totalCount: result.total,
1666
- fallbackTokenCount: fallbackTokens.length,
1703
+ queryFallbackTermCount: fallbackTokens.length,
1704
+ totalCountStrategy: fallbackTokenUsed ? "merged_length" : "source_total_sum",
1667
1705
  semanticFallbackEligible: telemetry.semanticFallbackEligible,
1668
1706
  semanticFallbackAttempted: telemetry.semanticFallbackAttempted,
1669
1707
  semanticFallbackUsed: telemetry.semanticFallbackUsed,
@@ -2047,6 +2085,7 @@ export class RecallXRepository {
2047
2085
  a.body,
2048
2086
  a.source_label,
2049
2087
  a.created_at,
2088
+ a.metadata_json,
2050
2089
  n.title AS target_title,
2051
2090
  n.type AS target_type,
2052
2091
  n.status AS target_status
@@ -2074,6 +2113,7 @@ export class RecallXRepository {
2074
2113
  body: row.body ? String(row.body) : null,
2075
2114
  sourceLabel: row.source_label ? String(row.source_label) : null,
2076
2115
  createdAt: String(row.created_at),
2116
+ metadata: parseJson(row.metadata_json, {}),
2077
2117
  lexicalQuality,
2078
2118
  matchReason: buildSearchMatchReason(params.strategy, signals.matchedFields, {
2079
2119
  strength: lexicalQuality === "none" ? undefined : lexicalQuality,
@@ -2417,6 +2457,12 @@ export class RecallXRepository {
2417
2457
  .all(...uniqueIds, ...uniqueIds);
2418
2458
  return rows.map(mapRelation);
2419
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
+ }
2420
2466
  createRelation(input) {
2421
2467
  const now = nowIso();
2422
2468
  const id = createId("rel");
@@ -2720,6 +2766,60 @@ export class RecallXRepository {
2720
2766
  .all(entityType, entityId, limit);
2721
2767
  return rows.map(mapGovernanceEvent);
2722
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
+ }
2723
2823
  upsertGovernanceState(params) {
2724
2824
  const now = params.lastEvaluatedAt ?? nowIso();
2725
2825
  const existing = params.previousState === undefined
@@ -3028,6 +3128,12 @@ export class RecallXRepository {
3028
3128
  .all(...uniqueIds, limit);
3029
3129
  return rows.map(mapActivity);
3030
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
+ }
3031
3137
  appendActivity(input) {
3032
3138
  const id = createId("act");
3033
3139
  const now = nowIso();
@@ -3110,6 +3216,12 @@ export class RecallXRepository {
3110
3216
  .all(nodeId);
3111
3217
  return rows.map(mapArtifact);
3112
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
+ }
3113
3225
  getArtifact(id) {
3114
3226
  const row = this.db.prepare(`SELECT * FROM artifacts WHERE id = ?`).get(id);
3115
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
+ }