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.
@@ -0,0 +1,572 @@
1
+ import { copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { RECALLX_VERSION } from "../shared/version.js";
4
+ import { AppError } from "./errors.js";
5
+ import { resolveNodeGovernance, resolveRelationStatus } from "./governance.js";
6
+ import { buildDuplicateIndex, buildPreviewFromPlan, detectDuplicateMatch, normalizeBody, normalizeTitle, rememberSeenNode, resolveImportOptions, } from "./workspace-import-helpers.js";
7
+ function sanitizeLabel(value, fallback) {
8
+ const trimmed = value?.trim();
9
+ if (!trimmed) {
10
+ return fallback;
11
+ }
12
+ return trimmed.replace(/[<>:"/\\|?*\x00-\x1f]+/g, "-").replace(/\s+/g, " ").trim() || fallback;
13
+ }
14
+ function slugify(value) {
15
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "import";
16
+ }
17
+ function buildImportSource(label) {
18
+ return {
19
+ actorType: "import",
20
+ actorLabel: label,
21
+ toolName: "recallx-import",
22
+ toolVersion: RECALLX_VERSION,
23
+ };
24
+ }
25
+ function resolveSourcePath(sourcePath) {
26
+ const resolved = path.resolve(sourcePath);
27
+ if (!existsSync(resolved)) {
28
+ throw new AppError(404, "IMPORT_SOURCE_NOT_FOUND", `Import source not found: ${resolved}`);
29
+ }
30
+ return resolved;
31
+ }
32
+ function copyImportSource(paths, sourcePath, label, now) {
33
+ const entry = lstatSync(sourcePath);
34
+ const stamp = now.replace(/[-:.TZ]/g, "").slice(0, 14);
35
+ const extension = entry.isDirectory() ? "" : path.extname(sourcePath);
36
+ const destination = path.join(paths.importsDir, `${stamp}-${slugify(label)}${extension}`);
37
+ mkdirSync(paths.importsDir, { recursive: true });
38
+ if (entry.isDirectory()) {
39
+ cpSync(sourcePath, destination, { recursive: true });
40
+ }
41
+ else {
42
+ copyFileSync(sourcePath, destination);
43
+ }
44
+ return destination;
45
+ }
46
+ function listMarkdownFiles(sourcePath) {
47
+ const entry = lstatSync(sourcePath);
48
+ if (entry.isDirectory()) {
49
+ const results = [];
50
+ for (const child of readdirSync(sourcePath, { withFileTypes: true })) {
51
+ const childPath = path.join(sourcePath, child.name);
52
+ if (child.isDirectory()) {
53
+ results.push(...listMarkdownFiles(childPath));
54
+ }
55
+ else if (child.isFile() && /\.(md|markdown)$/i.test(child.name)) {
56
+ results.push(childPath);
57
+ }
58
+ }
59
+ return results.sort();
60
+ }
61
+ if (entry.isFile() && /\.(md|markdown)$/i.test(sourcePath)) {
62
+ return [sourcePath];
63
+ }
64
+ throw new AppError(400, "INVALID_IMPORT_SOURCE", "Markdown import expects a .md file or a folder containing markdown files.");
65
+ }
66
+ function deriveMarkdownTitle(filePath, body) {
67
+ const heading = body
68
+ .split(/\r?\n/)
69
+ .map((line) => line.trim())
70
+ .find((line) => line.startsWith("# "));
71
+ if (heading) {
72
+ return heading.slice(2).trim() || path.basename(filePath, path.extname(filePath));
73
+ }
74
+ return path.basename(filePath, path.extname(filePath));
75
+ }
76
+ function asObject(value) {
77
+ return value && typeof value === "object" && !Array.isArray(value) ? { ...value } : {};
78
+ }
79
+ function asStringArray(value) {
80
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
81
+ }
82
+ function asActivityType(value) {
83
+ return value === "agent_run_summary" ||
84
+ value === "import_completed" ||
85
+ value === "artifact_attached" ||
86
+ value === "decision_recorded" ||
87
+ value === "review_action" ||
88
+ value === "context_bundle_generated"
89
+ ? value
90
+ : "note_appended";
91
+ }
92
+ function asNodeType(value) {
93
+ return value === "project" ||
94
+ value === "idea" ||
95
+ value === "question" ||
96
+ value === "decision" ||
97
+ value === "reference" ||
98
+ value === "artifact_ref" ||
99
+ value === "conversation"
100
+ ? value
101
+ : "note";
102
+ }
103
+ function asCanonicality(value) {
104
+ return value === "canonical" ||
105
+ value === "appended" ||
106
+ value === "suggested" ||
107
+ value === "imported" ||
108
+ value === "generated"
109
+ ? value
110
+ : undefined;
111
+ }
112
+ function asNodeStatus(value) {
113
+ return value === "active" ||
114
+ value === "draft" ||
115
+ value === "contested" ||
116
+ value === "archived"
117
+ ? value
118
+ : undefined;
119
+ }
120
+ function asRelationType(value) {
121
+ return value === "supports" ||
122
+ value === "contradicts" ||
123
+ value === "elaborates" ||
124
+ value === "depends_on" ||
125
+ value === "relevant_to" ||
126
+ value === "derived_from" ||
127
+ value === "produced_by"
128
+ ? value
129
+ : "related_to";
130
+ }
131
+ function asRelationStatus(value) {
132
+ return value === "active" ||
133
+ value === "suggested" ||
134
+ value === "rejected" ||
135
+ value === "archived"
136
+ ? value
137
+ : undefined;
138
+ }
139
+ function buildMarkdownPlan(params) {
140
+ const files = listMarkdownFiles(params.sourcePath);
141
+ if (!files.length) {
142
+ throw new AppError(400, "NO_MARKDOWN_FILES", "No markdown files were found to import.");
143
+ }
144
+ const existing = buildDuplicateIndex(params.repository.listAllNodes(), params.options);
145
+ const seen = {
146
+ exact: new Map(),
147
+ title: new Map(),
148
+ };
149
+ const nodes = files.map((filePath) => {
150
+ const rawBody = readFileSync(filePath, "utf8");
151
+ const body = normalizeBody(rawBody, params.options);
152
+ const title = normalizeTitle(deriveMarkdownTitle(filePath, rawBody), params.options);
153
+ const plannedNode = {
154
+ sourcePath: filePath,
155
+ title,
156
+ body,
157
+ type: "note",
158
+ tags: [],
159
+ metadata: {
160
+ importFormat: "markdown",
161
+ importLabel: params.label,
162
+ originalSourcePath: filePath,
163
+ },
164
+ originalId: null,
165
+ originalSourceLabel: null,
166
+ originalCreatedAt: null,
167
+ duplicate: null,
168
+ };
169
+ plannedNode.duplicate = detectDuplicateMatch({
170
+ node: plannedNode,
171
+ options: params.options,
172
+ existing,
173
+ seen,
174
+ });
175
+ rememberSeenNode(plannedNode, params.options, seen);
176
+ return plannedNode;
177
+ });
178
+ return {
179
+ format: "markdown",
180
+ label: params.label,
181
+ sourcePath: params.sourcePath,
182
+ createdAt: params.now,
183
+ options: params.options,
184
+ warnings: [],
185
+ nodes,
186
+ relations: [],
187
+ activities: [],
188
+ };
189
+ }
190
+ function buildRecallXJsonPlan(params) {
191
+ const raw = JSON.parse(readFileSync(params.sourcePath, "utf8"));
192
+ const existing = buildDuplicateIndex(params.repository.listAllNodes(), params.options);
193
+ const seen = {
194
+ exact: new Map(),
195
+ title: new Map(),
196
+ };
197
+ const warnings = [];
198
+ const nodes = (Array.isArray(raw.nodes) ? raw.nodes : []).map((rawNode, index) => {
199
+ const originalId = typeof rawNode.id === "string" ? rawNode.id : null;
200
+ const rawTitle = (typeof rawNode.title === "string" && rawNode.title.trim()) || originalId || `Imported node ${index + 1}`;
201
+ const body = normalizeBody(typeof rawNode.body === "string" ? rawNode.body : "", params.options);
202
+ const plannedNode = {
203
+ sourcePath: `${params.sourcePath}#node:${originalId ?? index + 1}`,
204
+ title: normalizeTitle(rawTitle, params.options),
205
+ body,
206
+ type: asNodeType(rawNode.type),
207
+ summary: typeof rawNode.summary === "string" ? rawNode.summary : undefined,
208
+ tags: asStringArray(rawNode.tags),
209
+ canonicality: asCanonicality(rawNode.canonicality),
210
+ status: asNodeStatus(rawNode.status),
211
+ metadata: {
212
+ ...asObject(rawNode.metadata),
213
+ importFormat: "recallx_json",
214
+ importLabel: params.label,
215
+ originalId,
216
+ originalSourceLabel: typeof rawNode.sourceLabel === "string" ? rawNode.sourceLabel : null,
217
+ originalCreatedAt: typeof rawNode.createdAt === "string" ? rawNode.createdAt : null,
218
+ },
219
+ originalId,
220
+ originalSourceLabel: typeof rawNode.sourceLabel === "string" ? rawNode.sourceLabel : null,
221
+ originalCreatedAt: typeof rawNode.createdAt === "string" ? rawNode.createdAt : null,
222
+ duplicate: null,
223
+ };
224
+ plannedNode.duplicate = detectDuplicateMatch({
225
+ node: plannedNode,
226
+ options: params.options,
227
+ existing,
228
+ seen,
229
+ });
230
+ rememberSeenNode(plannedNode, params.options, seen);
231
+ return plannedNode;
232
+ });
233
+ const relations = (Array.isArray(raw.relations) ? raw.relations : []).map((rawRelation) => ({
234
+ originalId: typeof rawRelation.id === "string" ? rawRelation.id : null,
235
+ fromOriginalId: typeof rawRelation.fromNodeId === "string" ? rawRelation.fromNodeId : null,
236
+ toOriginalId: typeof rawRelation.toNodeId === "string" ? rawRelation.toNodeId : null,
237
+ relationType: asRelationType(rawRelation.relationType),
238
+ status: asRelationStatus(rawRelation.status),
239
+ metadata: asObject(rawRelation.metadata),
240
+ }));
241
+ const activities = (Array.isArray(raw.activities) ? raw.activities : []).map((rawActivity) => ({
242
+ originalId: typeof rawActivity.id === "string" ? rawActivity.id : null,
243
+ targetOriginalId: typeof rawActivity.targetNodeId === "string" ? rawActivity.targetNodeId : null,
244
+ activityType: asActivityType(rawActivity.activityType),
245
+ body: typeof rawActivity.body === "string" ? rawActivity.body : "",
246
+ metadata: asObject(rawActivity.metadata),
247
+ originalCreatedAt: typeof rawActivity.createdAt === "string" ? rawActivity.createdAt : null,
248
+ }));
249
+ if ((raw.artifacts?.length ?? 0) > 0) {
250
+ warnings.push("Artifact files were not imported in this flow.");
251
+ }
252
+ if ((raw.integrations?.length ?? 0) > 0) {
253
+ warnings.push("Integration records were not imported in this flow.");
254
+ }
255
+ if (raw.settings && Object.keys(raw.settings).length > 0) {
256
+ warnings.push("Workspace settings were not imported in this flow.");
257
+ }
258
+ return {
259
+ format: "recallx_json",
260
+ label: params.label,
261
+ sourcePath: params.sourcePath,
262
+ createdAt: params.now,
263
+ options: params.options,
264
+ warnings,
265
+ nodes,
266
+ relations,
267
+ activities,
268
+ };
269
+ }
270
+ function buildImportPlan(params) {
271
+ const resolvedSourcePath = resolveSourcePath(params.sourcePath);
272
+ const label = sanitizeLabel(params.label, path.basename(resolvedSourcePath, path.extname(resolvedSourcePath)) || "Workspace import");
273
+ const options = resolveImportOptions(params.options);
274
+ return params.format === "markdown"
275
+ ? buildMarkdownPlan({
276
+ repository: params.repository,
277
+ sourcePath: resolvedSourcePath,
278
+ label,
279
+ now: params.now,
280
+ options,
281
+ })
282
+ : buildRecallXJsonPlan({
283
+ repository: params.repository,
284
+ sourcePath: resolvedSourcePath,
285
+ label,
286
+ now: params.now,
287
+ options,
288
+ });
289
+ }
290
+ function applyMarkdownPlan(params) {
291
+ const source = buildImportSource(params.plan.label);
292
+ let nodesCreated = 0;
293
+ let skippedNodes = 0;
294
+ for (const plannedNode of params.plan.nodes) {
295
+ if (params.plan.options.duplicateMode === "skip_exact" && plannedNode.duplicate?.matchType === "exact") {
296
+ skippedNodes += 1;
297
+ continue;
298
+ }
299
+ const nodeInput = {
300
+ type: plannedNode.type,
301
+ title: plannedNode.title,
302
+ body: plannedNode.body,
303
+ tags: plannedNode.tags,
304
+ source,
305
+ metadata: {
306
+ ...plannedNode.metadata,
307
+ importedSourcePath: params.importedPath,
308
+ importedAt: params.plan.createdAt,
309
+ },
310
+ };
311
+ const governance = resolveNodeGovernance(nodeInput);
312
+ const node = params.repository.createNode({
313
+ ...nodeInput,
314
+ resolvedCanonicality: governance.canonicality,
315
+ resolvedStatus: governance.status,
316
+ });
317
+ params.repository.recordProvenance({
318
+ entityType: "node",
319
+ entityId: node.id,
320
+ operationType: "import",
321
+ source,
322
+ metadata: {
323
+ originalSourcePath: plannedNode.sourcePath,
324
+ importedSourcePath: params.importedPath,
325
+ },
326
+ });
327
+ nodesCreated += 1;
328
+ }
329
+ const warnings = [...params.plan.warnings];
330
+ if (skippedNodes > 0) {
331
+ warnings.push(`Skipped ${skippedNodes} exact duplicate node(s).`);
332
+ }
333
+ return {
334
+ nodesCreated,
335
+ relationsCreated: 0,
336
+ activitiesCreated: 0,
337
+ skippedNodes,
338
+ skippedRelations: 0,
339
+ skippedActivities: 0,
340
+ warnings,
341
+ };
342
+ }
343
+ function applyRecallXJsonPlan(params) {
344
+ const source = buildImportSource(params.plan.label);
345
+ const nodeIdMap = new Map();
346
+ const skippedOriginalIds = new Set();
347
+ let nodesCreated = 0;
348
+ let relationsCreated = 0;
349
+ let activitiesCreated = 0;
350
+ let skippedNodes = 0;
351
+ let skippedRelations = 0;
352
+ let skippedActivities = 0;
353
+ for (const plannedNode of params.plan.nodes) {
354
+ if (params.plan.options.duplicateMode === "skip_exact" && plannedNode.duplicate?.matchType === "exact") {
355
+ skippedNodes += 1;
356
+ if (plannedNode.originalId) {
357
+ skippedOriginalIds.add(plannedNode.originalId);
358
+ }
359
+ continue;
360
+ }
361
+ const nodeInput = {
362
+ type: plannedNode.type,
363
+ title: plannedNode.title,
364
+ body: plannedNode.body,
365
+ summary: plannedNode.summary,
366
+ tags: plannedNode.tags,
367
+ canonicality: plannedNode.canonicality,
368
+ status: plannedNode.status,
369
+ source,
370
+ metadata: {
371
+ ...plannedNode.metadata,
372
+ importedSourcePath: params.importedPath,
373
+ importedAt: params.plan.createdAt,
374
+ },
375
+ };
376
+ const governance = resolveNodeGovernance(nodeInput);
377
+ const node = params.repository.createNode({
378
+ ...nodeInput,
379
+ resolvedCanonicality: governance.canonicality,
380
+ resolvedStatus: governance.status,
381
+ });
382
+ params.repository.recordProvenance({
383
+ entityType: "node",
384
+ entityId: node.id,
385
+ operationType: "import",
386
+ source,
387
+ metadata: {
388
+ originalId: plannedNode.originalId,
389
+ importedSourcePath: params.importedPath,
390
+ },
391
+ });
392
+ if (plannedNode.originalId) {
393
+ nodeIdMap.set(plannedNode.originalId, node.id);
394
+ }
395
+ nodesCreated += 1;
396
+ }
397
+ for (const plannedRelation of params.plan.relations) {
398
+ if ((plannedRelation.fromOriginalId && skippedOriginalIds.has(plannedRelation.fromOriginalId)) ||
399
+ (plannedRelation.toOriginalId && skippedOriginalIds.has(plannedRelation.toOriginalId))) {
400
+ skippedRelations += 1;
401
+ continue;
402
+ }
403
+ const fromNodeId = plannedRelation.fromOriginalId ? nodeIdMap.get(plannedRelation.fromOriginalId) : null;
404
+ const toNodeId = plannedRelation.toOriginalId ? nodeIdMap.get(plannedRelation.toOriginalId) : null;
405
+ if (!fromNodeId || !toNodeId) {
406
+ skippedRelations += 1;
407
+ continue;
408
+ }
409
+ const relationInput = {
410
+ fromNodeId,
411
+ toNodeId,
412
+ relationType: plannedRelation.relationType,
413
+ status: plannedRelation.status,
414
+ source,
415
+ metadata: {
416
+ ...plannedRelation.metadata,
417
+ originalId: plannedRelation.originalId,
418
+ importedSourcePath: params.importedPath,
419
+ },
420
+ };
421
+ const resolved = resolveRelationStatus(relationInput);
422
+ const relation = params.repository.createRelation({
423
+ ...relationInput,
424
+ resolvedStatus: resolved.status,
425
+ });
426
+ params.repository.recordProvenance({
427
+ entityType: "relation",
428
+ entityId: relation.id,
429
+ operationType: "import",
430
+ source,
431
+ metadata: {
432
+ originalId: plannedRelation.originalId,
433
+ importedSourcePath: params.importedPath,
434
+ },
435
+ });
436
+ relationsCreated += 1;
437
+ }
438
+ for (const plannedActivity of params.plan.activities) {
439
+ if (plannedActivity.targetOriginalId && skippedOriginalIds.has(plannedActivity.targetOriginalId)) {
440
+ skippedActivities += 1;
441
+ continue;
442
+ }
443
+ const targetNodeId = plannedActivity.targetOriginalId ? nodeIdMap.get(plannedActivity.targetOriginalId) : null;
444
+ if (!targetNodeId) {
445
+ skippedActivities += 1;
446
+ continue;
447
+ }
448
+ const activity = params.repository.appendActivity({
449
+ targetNodeId,
450
+ activityType: plannedActivity.activityType,
451
+ body: plannedActivity.body,
452
+ source,
453
+ metadata: {
454
+ ...plannedActivity.metadata,
455
+ originalId: plannedActivity.originalId,
456
+ originalCreatedAt: plannedActivity.originalCreatedAt,
457
+ importedSourcePath: params.importedPath,
458
+ },
459
+ });
460
+ params.repository.recordProvenance({
461
+ entityType: "activity",
462
+ entityId: activity.id,
463
+ operationType: "import",
464
+ source,
465
+ metadata: {
466
+ originalId: plannedActivity.originalId,
467
+ importedSourcePath: params.importedPath,
468
+ },
469
+ });
470
+ activitiesCreated += 1;
471
+ }
472
+ const warnings = [...params.plan.warnings];
473
+ if (skippedNodes > 0) {
474
+ warnings.push(`Skipped ${skippedNodes} exact duplicate node(s).`);
475
+ }
476
+ return {
477
+ nodesCreated,
478
+ relationsCreated,
479
+ activitiesCreated,
480
+ skippedNodes,
481
+ skippedRelations,
482
+ skippedActivities,
483
+ warnings,
484
+ };
485
+ }
486
+ function applyImportPlan(params) {
487
+ const counts = params.plan.format === "markdown"
488
+ ? applyMarkdownPlan({
489
+ repository: params.repository,
490
+ plan: params.plan,
491
+ importedPath: params.importedPath,
492
+ })
493
+ : applyRecallXJsonPlan({
494
+ repository: params.repository,
495
+ plan: params.plan,
496
+ importedPath: params.importedPath,
497
+ });
498
+ const source = buildImportSource(params.plan.label);
499
+ const inboxNode = params.repository.ensureWorkspaceInboxNode();
500
+ const skippedSummary = counts.skippedNodes || counts.skippedRelations || counts.skippedActivities
501
+ ? ` Skipped ${counts.skippedNodes} node(s), ${counts.skippedRelations} relation(s), and ${counts.skippedActivities} activity item(s).`
502
+ : "";
503
+ const summaryActivity = params.repository.appendActivity({
504
+ targetNodeId: inboxNode.id,
505
+ activityType: "import_completed",
506
+ body: `Imported ${counts.nodesCreated} node(s), ${counts.relationsCreated} relation(s), and ${counts.activitiesCreated} activity item(s) from ${path.basename(params.plan.sourcePath)}.` +
507
+ skippedSummary,
508
+ source,
509
+ metadata: {
510
+ importFormat: params.plan.format,
511
+ importLabel: params.plan.label,
512
+ sourcePath: params.plan.sourcePath,
513
+ importedPath: params.importedPath,
514
+ backupId: params.backup.id,
515
+ backupPath: params.backup.backupPath,
516
+ options: params.plan.options,
517
+ warnings: counts.warnings,
518
+ skippedNodes: counts.skippedNodes,
519
+ skippedRelations: counts.skippedRelations,
520
+ skippedActivities: counts.skippedActivities,
521
+ },
522
+ });
523
+ params.repository.recordProvenance({
524
+ entityType: "activity",
525
+ entityId: summaryActivity.id,
526
+ operationType: "import",
527
+ source,
528
+ metadata: {
529
+ sourcePath: params.plan.sourcePath,
530
+ importedPath: params.importedPath,
531
+ backupId: params.backup.id,
532
+ },
533
+ });
534
+ return {
535
+ format: params.plan.format,
536
+ label: params.plan.label,
537
+ sourcePath: params.plan.sourcePath,
538
+ importedPath: params.importedPath,
539
+ createdAt: params.plan.createdAt,
540
+ options: params.plan.options,
541
+ backupId: params.backup.id,
542
+ backupPath: params.backup.backupPath,
543
+ nodesCreated: counts.nodesCreated,
544
+ relationsCreated: counts.relationsCreated,
545
+ activitiesCreated: counts.activitiesCreated + 1,
546
+ skippedNodes: counts.skippedNodes,
547
+ skippedRelations: counts.skippedRelations,
548
+ skippedActivities: counts.skippedActivities,
549
+ warnings: counts.warnings,
550
+ };
551
+ }
552
+ export function previewImportIntoWorkspace(params) {
553
+ const plan = buildImportPlan(params);
554
+ return buildPreviewFromPlan(plan);
555
+ }
556
+ export function importIntoWorkspace(params) {
557
+ const plan = buildImportPlan({
558
+ repository: params.repository,
559
+ format: params.format,
560
+ sourcePath: params.sourcePath,
561
+ label: params.label,
562
+ now: params.now,
563
+ options: params.options,
564
+ });
565
+ const importedPath = copyImportSource(params.paths, plan.sourcePath, plan.label, plan.createdAt);
566
+ return applyImportPlan({
567
+ repository: params.repository,
568
+ plan,
569
+ importedPath,
570
+ backup: params.backup,
571
+ });
572
+ }