recallx 1.0.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.
Files changed (37) hide show
  1. package/README.md +205 -0
  2. package/app/cli/bin/recallx-mcp.js +2 -0
  3. package/app/cli/bin/recallx.js +8 -0
  4. package/app/cli/src/cli.js +808 -0
  5. package/app/cli/src/format.js +242 -0
  6. package/app/cli/src/http.js +35 -0
  7. package/app/mcp/api-client.js +101 -0
  8. package/app/mcp/index.js +128 -0
  9. package/app/mcp/server.js +786 -0
  10. package/app/server/app.js +2263 -0
  11. package/app/server/config.js +27 -0
  12. package/app/server/db.js +399 -0
  13. package/app/server/errors.js +17 -0
  14. package/app/server/governance.js +466 -0
  15. package/app/server/index.js +26 -0
  16. package/app/server/inferred-relations.js +247 -0
  17. package/app/server/observability.js +495 -0
  18. package/app/server/project-graph.js +199 -0
  19. package/app/server/relation-scoring.js +59 -0
  20. package/app/server/repositories.js +2992 -0
  21. package/app/server/retrieval.js +486 -0
  22. package/app/server/semantic/chunker.js +85 -0
  23. package/app/server/semantic/provider.js +124 -0
  24. package/app/server/semantic/types.js +1 -0
  25. package/app/server/semantic/vector-store.js +169 -0
  26. package/app/server/utils.js +43 -0
  27. package/app/server/workspace-session.js +128 -0
  28. package/app/server/workspace.js +79 -0
  29. package/app/shared/contracts.js +268 -0
  30. package/app/shared/request-runtime.js +30 -0
  31. package/app/shared/types.js +1 -0
  32. package/app/shared/version.js +1 -0
  33. package/dist/renderer/assets/ProjectGraphCanvas-BMvz9DmE.js +312 -0
  34. package/dist/renderer/assets/index-C2-KXqBO.css +1 -0
  35. package/dist/renderer/assets/index-CrDu22h7.js +76 -0
  36. package/dist/renderer/index.html +13 -0
  37. package/package.json +49 -0
@@ -0,0 +1,495 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { appendFile, mkdir, readFile, readdir, unlink } from "node:fs/promises";
3
+ import path from "node:path";
4
+ const telemetryStorage = new AsyncLocalStorage();
5
+ function nowIso() {
6
+ return new Date().toISOString();
7
+ }
8
+ function parseJsonLine(line) {
9
+ if (!line.trim()) {
10
+ return null;
11
+ }
12
+ try {
13
+ return JSON.parse(line);
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ function roundDuration(value) {
20
+ return Number(value.toFixed(2));
21
+ }
22
+ function dateStamp(value) {
23
+ return value.slice(0, 10);
24
+ }
25
+ function normalizeRetentionDays(value) {
26
+ return Math.max(1, Math.trunc(value || 14));
27
+ }
28
+ function normalizeLimit(value) {
29
+ if (typeof value !== "number" || !Number.isFinite(value)) {
30
+ return 50;
31
+ }
32
+ return Math.min(Math.max(Math.trunc(value), 1), 200);
33
+ }
34
+ function percentiles(durations) {
35
+ if (!durations.length) {
36
+ return {
37
+ avg: null,
38
+ p50: null,
39
+ p95: null,
40
+ p99: null
41
+ };
42
+ }
43
+ const sorted = [...durations].sort((left, right) => left - right);
44
+ const read = (percentile) => sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * percentile) - 1))];
45
+ const avg = sorted.reduce((sum, value) => sum + value, 0) / sorted.length;
46
+ return {
47
+ avg: roundDuration(avg),
48
+ p50: roundDuration(read(0.5)),
49
+ p95: roundDuration(read(0.95)),
50
+ p99: roundDuration(read(0.99))
51
+ };
52
+ }
53
+ function parseSince(since) {
54
+ const normalized = since?.trim();
55
+ if (!normalized) {
56
+ return Date.now() - 24 * 60 * 60 * 1000;
57
+ }
58
+ const absolute = Date.parse(normalized);
59
+ if (Number.isFinite(absolute)) {
60
+ return absolute;
61
+ }
62
+ const relativeMatch = normalized.match(/^(\d+)([smhd])$/i);
63
+ if (!relativeMatch) {
64
+ return Date.now() - 24 * 60 * 60 * 1000;
65
+ }
66
+ const amount = Number(relativeMatch[1]);
67
+ const unit = relativeMatch[2].toLowerCase();
68
+ const multiplier = unit === "s"
69
+ ? 1000
70
+ : unit === "m"
71
+ ? 60 * 1000
72
+ : unit === "h"
73
+ ? 60 * 60 * 1000
74
+ : 24 * 60 * 60 * 1000;
75
+ return Date.now() - amount * multiplier;
76
+ }
77
+ function buildPayloadShapeSummary(value) {
78
+ if (Array.isArray(value)) {
79
+ return {
80
+ argCount: value.length
81
+ };
82
+ }
83
+ if (!value || typeof value !== "object") {
84
+ return {};
85
+ }
86
+ return {
87
+ argKeys: Object.keys(value).sort(),
88
+ argCount: Object.keys(value).length
89
+ };
90
+ }
91
+ function sanitizeDetails(details) {
92
+ if (!details) {
93
+ return {};
94
+ }
95
+ const sanitized = {};
96
+ const shouldSkipKey = (key) => {
97
+ const normalized = key.replace(/[^a-z0-9]/gi, "").toLowerCase();
98
+ return ["body", "summary", "metadata", "token", "authorization", "artifact", "content"].some((part) => normalized.includes(part));
99
+ };
100
+ for (const [key, value] of Object.entries(details)) {
101
+ if (shouldSkipKey(key)) {
102
+ continue;
103
+ }
104
+ if (value === undefined) {
105
+ continue;
106
+ }
107
+ if (value === null || typeof value === "number" || typeof value === "boolean") {
108
+ sanitized[key] = value;
109
+ continue;
110
+ }
111
+ if (typeof value === "string") {
112
+ sanitized[key] = value.length > 200 ? `${value.slice(0, 197)}...` : value;
113
+ continue;
114
+ }
115
+ if (Array.isArray(value)) {
116
+ sanitized[key] = value
117
+ .filter((item) => item === null || typeof item === "string" || typeof item === "number" || typeof item === "boolean")
118
+ .slice(0, 20);
119
+ continue;
120
+ }
121
+ if (typeof value === "object") {
122
+ const flat = {};
123
+ for (const [innerKey, innerValue] of Object.entries(value)) {
124
+ if (shouldSkipKey(innerKey)) {
125
+ continue;
126
+ }
127
+ if (innerValue === null ||
128
+ typeof innerValue === "string" ||
129
+ typeof innerValue === "number" ||
130
+ typeof innerValue === "boolean") {
131
+ flat[innerKey] = typeof innerValue === "string" && innerValue.length > 200
132
+ ? `${innerValue.slice(0, 197)}...`
133
+ : innerValue;
134
+ }
135
+ }
136
+ sanitized[key] = flat;
137
+ }
138
+ }
139
+ return sanitized;
140
+ }
141
+ export class TelemetrySpan {
142
+ writer;
143
+ state;
144
+ context;
145
+ operation;
146
+ startedAt = process.hrtime.bigint();
147
+ details;
148
+ finished = false;
149
+ constructor(writer, state, context, operation, details) {
150
+ this.writer = writer;
151
+ this.state = state;
152
+ this.context = context;
153
+ this.operation = operation;
154
+ this.details = sanitizeDetails(details);
155
+ }
156
+ addDetails(details) {
157
+ Object.assign(this.details, sanitizeDetails(details));
158
+ }
159
+ async finish(input = {}) {
160
+ if (this.finished) {
161
+ return;
162
+ }
163
+ this.finished = true;
164
+ const durationMs = roundDuration(Number(process.hrtime.bigint() - this.startedAt) / 1_000_000);
165
+ await this.writer.enqueue({
166
+ ts: nowIso(),
167
+ traceId: this.context.traceId,
168
+ requestId: input.requestId ?? this.context.requestId,
169
+ surface: this.context.surface,
170
+ operation: this.operation,
171
+ outcome: input.outcome ?? "success",
172
+ durationMs,
173
+ statusCode: input.statusCode ?? null,
174
+ errorCode: input.errorCode ?? null,
175
+ errorKind: input.errorKind ?? null,
176
+ workspaceName: this.state.workspaceName,
177
+ details: sanitizeDetails({
178
+ ...this.details,
179
+ ...input.details
180
+ })
181
+ }, this.state);
182
+ }
183
+ run(callback) {
184
+ return telemetryStorage.run({
185
+ ...this.context,
186
+ spans: [...this.context.spans, this]
187
+ }, callback);
188
+ }
189
+ }
190
+ export class ObservabilityWriter {
191
+ options;
192
+ pendingWrites = new Set();
193
+ retentionRuns = new Map();
194
+ constructor(options) {
195
+ this.options = options;
196
+ }
197
+ currentContext() {
198
+ return telemetryStorage.getStore() ?? null;
199
+ }
200
+ withContext(input, callback) {
201
+ return telemetryStorage.run({
202
+ ...input,
203
+ spans: []
204
+ }, callback);
205
+ }
206
+ startSpan(input) {
207
+ const state = this.options.getState();
208
+ const current = telemetryStorage.getStore();
209
+ const context = {
210
+ traceId: input.traceId ?? current?.traceId ?? "trace_unknown",
211
+ requestId: input.requestId ?? current?.requestId ?? null,
212
+ workspaceRoot: current?.workspaceRoot ?? state.workspaceRoot,
213
+ workspaceName: current?.workspaceName ?? state.workspaceName,
214
+ surface: input.surface ?? current?.surface ?? "api",
215
+ toolName: current?.toolName ?? null,
216
+ spans: current?.spans ?? []
217
+ };
218
+ return new TelemetrySpan(this, state, context, input.operation, input.details);
219
+ }
220
+ addCurrentSpanDetails(details) {
221
+ const current = telemetryStorage.getStore();
222
+ current?.spans[current.spans.length - 1]?.addDetails(details);
223
+ }
224
+ async recordEvent(input) {
225
+ const state = this.options.getState();
226
+ if (!state.enabled) {
227
+ return;
228
+ }
229
+ const current = telemetryStorage.getStore();
230
+ await this.enqueue({
231
+ ts: nowIso(),
232
+ traceId: input.traceId ?? current?.traceId ?? "trace_unknown",
233
+ requestId: input.requestId ?? current?.requestId ?? null,
234
+ surface: input.surface ?? current?.surface ?? "api",
235
+ operation: input.operation,
236
+ outcome: input.outcome ?? "success",
237
+ durationMs: input.durationMs ?? null,
238
+ statusCode: input.statusCode ?? null,
239
+ errorCode: input.errorCode ?? null,
240
+ errorKind: input.errorKind ?? null,
241
+ workspaceName: state.workspaceName,
242
+ details: sanitizeDetails(input.details)
243
+ }, state);
244
+ }
245
+ async recordError(input) {
246
+ await this.recordEvent({
247
+ ...input,
248
+ outcome: "error"
249
+ });
250
+ }
251
+ async enqueue(event, state) {
252
+ if (!state.enabled) {
253
+ return;
254
+ }
255
+ void this.pruneLogsIfNeeded(state);
256
+ const filePath = path.join(state.workspaceRoot, "logs", `telemetry-${dateStamp(event.ts)}.ndjson`);
257
+ const write = (async () => {
258
+ await mkdir(path.dirname(filePath), { recursive: true });
259
+ await appendFile(filePath, `${JSON.stringify(event)}\n`, "utf8");
260
+ })();
261
+ this.pendingWrites.add(write);
262
+ write.finally(() => {
263
+ this.pendingWrites.delete(write);
264
+ }).catch(() => { });
265
+ }
266
+ async flush() {
267
+ await Promise.allSettled([...this.pendingWrites]);
268
+ }
269
+ async pruneLogsIfNeeded(state) {
270
+ const retentionDays = normalizeRetentionDays(state.retentionDays);
271
+ const today = dateStamp(nowIso());
272
+ const workspaceKey = `${state.workspaceRoot}:${retentionDays}`;
273
+ if (this.retentionRuns.get(workspaceKey) === today) {
274
+ return;
275
+ }
276
+ this.retentionRuns.set(workspaceKey, today);
277
+ const cutoff = new Date();
278
+ cutoff.setUTCDate(cutoff.getUTCDate() - retentionDays);
279
+ const cutoffStamp = dateStamp(cutoff.toISOString());
280
+ const logsDir = path.join(state.workspaceRoot, "logs");
281
+ let entries;
282
+ try {
283
+ entries = await readdir(logsDir);
284
+ }
285
+ catch {
286
+ return;
287
+ }
288
+ await Promise.all(entries
289
+ .filter((entry) => /^telemetry-\d{4}-\d{2}-\d{2}\.ndjson$/.test(entry))
290
+ .filter((entry) => entry.slice("telemetry-".length, "telemetry-".length + 10) < cutoffStamp)
291
+ .map((entry) => unlink(path.join(logsDir, entry)).catch(() => { })));
292
+ }
293
+ async readEvents(options) {
294
+ const state = this.options.getState();
295
+ const sinceMs = parseSince(options.since);
296
+ const logsDir = path.join(state.workspaceRoot, "logs");
297
+ void this.pruneLogsIfNeeded(state);
298
+ let entries;
299
+ try {
300
+ entries = await readdir(logsDir);
301
+ }
302
+ catch {
303
+ return {
304
+ logsPath: logsDir,
305
+ events: [],
306
+ since: new Date(sinceMs).toISOString()
307
+ };
308
+ }
309
+ const files = entries
310
+ .filter((entry) => /^telemetry-\d{4}-\d{2}-\d{2}\.ndjson$/.test(entry))
311
+ .sort();
312
+ const events = [];
313
+ for (const file of files) {
314
+ const filePath = path.join(logsDir, file);
315
+ const content = await readFile(filePath, "utf8").catch(() => "");
316
+ for (const line of content.split("\n")) {
317
+ const event = parseJsonLine(line);
318
+ if (!event) {
319
+ continue;
320
+ }
321
+ const eventMs = Date.parse(event.ts);
322
+ if (!Number.isFinite(eventMs) || eventMs < sinceMs) {
323
+ continue;
324
+ }
325
+ if (options.surface && options.surface !== "all" && event.surface !== options.surface) {
326
+ continue;
327
+ }
328
+ events.push(event);
329
+ }
330
+ }
331
+ return {
332
+ logsPath: logsDir,
333
+ events,
334
+ since: new Date(sinceMs).toISOString()
335
+ };
336
+ }
337
+ async summarize(options) {
338
+ const state = this.options.getState();
339
+ const { logsPath, events, since } = await this.readEvents(options);
340
+ const buckets = new Map();
341
+ const mcpFailures = new Map();
342
+ const autoJobs = new Map();
343
+ let ftsFallbackCount = 0;
344
+ let ftsSampleCount = 0;
345
+ let semanticUsedCount = 0;
346
+ let semanticSampleCount = 0;
347
+ let semanticFallbackEligibleCount = 0;
348
+ let semanticFallbackAttemptedCount = 0;
349
+ let semanticFallbackHitCount = 0;
350
+ for (const event of events) {
351
+ if (typeof event.details.ftsFallback === "boolean") {
352
+ ftsSampleCount += 1;
353
+ if (event.details.ftsFallback) {
354
+ ftsFallbackCount += 1;
355
+ }
356
+ }
357
+ if (typeof event.details.semanticUsed === "boolean") {
358
+ semanticSampleCount += 1;
359
+ if (event.details.semanticUsed) {
360
+ semanticUsedCount += 1;
361
+ }
362
+ }
363
+ if (typeof event.details.semanticFallbackEligible === "boolean") {
364
+ if (event.details.semanticFallbackEligible) {
365
+ semanticFallbackEligibleCount += 1;
366
+ }
367
+ }
368
+ if (typeof event.details.semanticFallbackAttempted === "boolean") {
369
+ if (event.details.semanticFallbackAttempted) {
370
+ semanticFallbackAttemptedCount += 1;
371
+ }
372
+ }
373
+ if (typeof event.details.semanticFallbackUsed === "boolean") {
374
+ if (event.details.semanticFallbackUsed) {
375
+ semanticFallbackHitCount += 1;
376
+ }
377
+ }
378
+ if (event.durationMs != null) {
379
+ const bucketKey = `${event.surface}:${event.operation}`;
380
+ const current = buckets.get(bucketKey) ??
381
+ {
382
+ summary: {
383
+ surface: event.surface,
384
+ operation: event.operation,
385
+ count: 0,
386
+ errorCount: 0,
387
+ errorRate: 0,
388
+ avgDurationMs: null,
389
+ p50DurationMs: null,
390
+ p95DurationMs: null,
391
+ p99DurationMs: null
392
+ },
393
+ durations: []
394
+ };
395
+ current.summary.count += 1;
396
+ if (event.outcome === "error") {
397
+ current.summary.errorCount += 1;
398
+ }
399
+ current.durations.push(event.durationMs);
400
+ buckets.set(bucketKey, current);
401
+ }
402
+ if (event.surface === "mcp" && event.outcome === "error") {
403
+ mcpFailures.set(event.operation, (mcpFailures.get(event.operation) ?? 0) + 1);
404
+ }
405
+ if (event.operation.startsWith("auto.")) {
406
+ const durations = autoJobs.get(event.operation) ?? [];
407
+ if (event.durationMs != null) {
408
+ durations.push(event.durationMs);
409
+ }
410
+ autoJobs.set(event.operation, durations);
411
+ }
412
+ }
413
+ const operationSummaries = [...buckets.values()]
414
+ .map(({ summary, durations }) => {
415
+ const stats = percentiles(durations);
416
+ return {
417
+ ...summary,
418
+ errorRate: summary.count > 0 ? Number((summary.errorCount / summary.count).toFixed(4)) : 0,
419
+ avgDurationMs: stats.avg,
420
+ p50DurationMs: stats.p50,
421
+ p95DurationMs: stats.p95,
422
+ p99DurationMs: stats.p99
423
+ };
424
+ })
425
+ .sort((left, right) => (right.p95DurationMs ?? 0) - (left.p95DurationMs ?? 0));
426
+ return {
427
+ since,
428
+ generatedAt: nowIso(),
429
+ logsPath,
430
+ totalEvents: events.length,
431
+ operationSummaries,
432
+ slowOperations: operationSummaries
433
+ .filter((item) => (item.p95DurationMs ?? 0) >= state.slowRequestMs)
434
+ .slice(0, 10),
435
+ mcpToolFailures: [...mcpFailures.entries()]
436
+ .map(([operation, count]) => ({ operation, count }))
437
+ .sort((left, right) => right.count - left.count),
438
+ ftsFallbackRate: {
439
+ fallbackCount: ftsFallbackCount,
440
+ sampleCount: ftsSampleCount,
441
+ ratio: ftsSampleCount > 0 ? Number((ftsFallbackCount / ftsSampleCount).toFixed(4)) : null
442
+ },
443
+ semanticAugmentationRate: {
444
+ usedCount: semanticUsedCount,
445
+ sampleCount: semanticSampleCount,
446
+ ratio: semanticSampleCount > 0 ? Number((semanticUsedCount / semanticSampleCount).toFixed(4)) : null
447
+ },
448
+ semanticFallbackRate: {
449
+ eligibleCount: semanticFallbackEligibleCount,
450
+ attemptedCount: semanticFallbackAttemptedCount,
451
+ hitCount: semanticFallbackHitCount,
452
+ attemptRatio: semanticFallbackEligibleCount > 0
453
+ ? Number((semanticFallbackAttemptedCount / semanticFallbackEligibleCount).toFixed(4))
454
+ : null,
455
+ hitRatio: semanticFallbackAttemptedCount > 0
456
+ ? Number((semanticFallbackHitCount / semanticFallbackAttemptedCount).toFixed(4))
457
+ : null
458
+ },
459
+ autoJobStats: [...autoJobs.entries()].map(([operation, durations]) => ({
460
+ operation,
461
+ count: durations.length,
462
+ avgDurationMs: durations.length ? roundDuration(durations.reduce((sum, value) => sum + value, 0) / durations.length) : null
463
+ }))
464
+ };
465
+ }
466
+ async listErrors(options) {
467
+ const { logsPath, events, since } = await this.readEvents(options);
468
+ return {
469
+ since,
470
+ generatedAt: nowIso(),
471
+ surface: options.surface ?? "all",
472
+ logsPath,
473
+ items: events
474
+ .filter((event) => event.outcome === "error")
475
+ .sort((left, right) => right.ts.localeCompare(left.ts))
476
+ .slice(0, normalizeLimit(options.limit))
477
+ };
478
+ }
479
+ }
480
+ export function createObservabilityWriter(options) {
481
+ return new ObservabilityWriter(options);
482
+ }
483
+ export function currentTelemetryContext() {
484
+ return telemetryStorage.getStore() ?? null;
485
+ }
486
+ export function appendCurrentTelemetryDetails(details) {
487
+ const current = telemetryStorage.getStore();
488
+ current?.spans[current.spans.length - 1]?.addDetails(details);
489
+ }
490
+ export function summarizePayloadShape(value) {
491
+ return buildPayloadShapeSummary(value);
492
+ }
493
+ export function parseTelemetrySince(value) {
494
+ return new Date(parseSince(value)).toISOString();
495
+ }
@@ -0,0 +1,199 @@
1
+ import { AppError } from "./errors.js";
2
+ const DEFAULT_PROJECT_MEMBER_LIMIT = 120;
3
+ const DEFAULT_PROJECT_ACTIVITY_LIMIT = 200;
4
+ const DEFAULT_PROJECT_FALLBACK_NODE_LIMIT = 8;
5
+ const DEFAULT_PROJECT_INFERRED_LIMIT = 60;
6
+ function relationLabel(value) {
7
+ return value.replaceAll("_", " ");
8
+ }
9
+ export function buildProjectGraph(repository, projectId, options) {
10
+ const project = repository.getNode(projectId);
11
+ if (project.type !== "project") {
12
+ throw new AppError(400, "INVALID_INPUT", "Project graph only supports project nodes.");
13
+ }
14
+ const includeInferred = options?.includeInferred ?? true;
15
+ const memberLimit = options?.memberLimit ?? DEFAULT_PROJECT_MEMBER_LIMIT;
16
+ const activityLimit = options?.activityLimit ?? DEFAULT_PROJECT_ACTIVITY_LIMIT;
17
+ const inferredLimit = options?.maxInferred ?? DEFAULT_PROJECT_INFERRED_LIMIT;
18
+ const membership = repository.listProjectMemberNodes(projectId, memberLimit);
19
+ const scopedNodeIdSet = new Set([projectId, ...membership.map(({ node }) => node.id)]);
20
+ const scopedNodeIds = Array.from(scopedNodeIdSet);
21
+ const scopedNodes = repository.getNodesByIds(scopedNodeIds);
22
+ scopedNodes.set(project.id, project);
23
+ let canonicalEdges = repository.listRelationsBetweenNodeIds(scopedNodeIds);
24
+ let inferredEdges = includeInferred ? repository.listInferredRelationsBetweenNodeIds(scopedNodeIds, inferredLimit) : [];
25
+ const fallbackNodeIds = scopedNodeIdSet.size <= 1 && canonicalEdges.length === 0 && inferredEdges.length === 0
26
+ ? repository
27
+ .searchNodes({
28
+ query: "",
29
+ filters: {
30
+ types: ["note", "idea", "question", "decision", "reference", "artifact_ref"],
31
+ status: ["active"]
32
+ },
33
+ limit: DEFAULT_PROJECT_FALLBACK_NODE_LIMIT,
34
+ offset: 0,
35
+ sort: "updated_at"
36
+ })
37
+ .items
38
+ .filter((item) => item.id !== projectId)
39
+ .map((item) => item.id)
40
+ .filter((nodeId, index, items) => items.indexOf(nodeId) === index)
41
+ : [];
42
+ const fallbackNodeMap = fallbackNodeIds.length > 0 ? repository.getNodesByIds(fallbackNodeIds) : new Map();
43
+ const fallbackNodes = fallbackNodeIds.length > 0
44
+ ? fallbackNodeIds
45
+ .map((nodeId) => fallbackNodeMap.get(nodeId))
46
+ .filter((node) => Boolean(node))
47
+ : [];
48
+ for (const node of fallbackNodes) {
49
+ scopedNodeIdSet.add(node.id);
50
+ scopedNodes.set(node.id, node);
51
+ }
52
+ if (fallbackNodes.length) {
53
+ const expandedScopedNodeIds = Array.from(scopedNodeIdSet);
54
+ canonicalEdges = repository.listRelationsBetweenNodeIds(expandedScopedNodeIds);
55
+ inferredEdges = includeInferred ? repository.listInferredRelationsBetweenNodeIds(expandedScopedNodeIds, inferredLimit) : [];
56
+ }
57
+ const directEdgeKeys = new Set(canonicalEdges.map((edge) => `${edge.fromNodeId}:${edge.toNodeId}`));
58
+ const syntheticFallbackEdges = fallbackNodes
59
+ .filter((node) => !directEdgeKeys.has(`${node.id}:${projectId}`) && !directEdgeKeys.has(`${projectId}:${node.id}`))
60
+ .map((node) => ({
61
+ id: `project-map-fallback:${projectId}:${node.id}`,
62
+ source: projectId,
63
+ target: node.id,
64
+ relationType: "related_to",
65
+ relationSource: "inferred",
66
+ status: "active",
67
+ score: 0.2,
68
+ generator: "project-map-fallback",
69
+ createdAt: node.updatedAt,
70
+ evidence: {
71
+ strategy: "workspace_recent",
72
+ reason: "Recent active workspace node used as exploratory seed because the project has no explicit membership graph yet."
73
+ }
74
+ }));
75
+ const allEdges = [
76
+ ...canonicalEdges.map((edge) => ({
77
+ id: edge.id,
78
+ source: edge.fromNodeId,
79
+ target: edge.toNodeId,
80
+ relationType: edge.relationType,
81
+ relationSource: "canonical",
82
+ status: edge.status,
83
+ score: null,
84
+ generator: null,
85
+ createdAt: edge.createdAt,
86
+ evidence: undefined,
87
+ })),
88
+ ...inferredEdges.map((edge) => ({
89
+ id: edge.id,
90
+ source: edge.fromNodeId,
91
+ target: edge.toNodeId,
92
+ relationType: edge.relationType,
93
+ relationSource: "inferred",
94
+ status: edge.status,
95
+ score: edge.finalScore,
96
+ generator: edge.generator,
97
+ createdAt: edge.lastComputedAt,
98
+ evidence: edge.evidence,
99
+ })),
100
+ ...syntheticFallbackEdges,
101
+ ];
102
+ const degreeByNodeId = new Map();
103
+ for (const edge of allEdges) {
104
+ degreeByNodeId.set(edge.source, (degreeByNodeId.get(edge.source) ?? 0) + 1);
105
+ degreeByNodeId.set(edge.target, (degreeByNodeId.get(edge.target) ?? 0) + 1);
106
+ }
107
+ const nodes = Array.from(scopedNodeIdSet)
108
+ .map((nodeId) => scopedNodes.get(nodeId))
109
+ .filter((node) => Boolean(node))
110
+ .map((node) => ({
111
+ id: node.id,
112
+ title: node.title,
113
+ type: node.type,
114
+ status: node.status,
115
+ canonicality: node.canonicality,
116
+ summary: node.summary,
117
+ createdAt: node.createdAt,
118
+ updatedAt: node.updatedAt,
119
+ degree: degreeByNodeId.get(node.id) ?? 0,
120
+ isFocus: node.id === projectId,
121
+ projectRole: node.id === projectId ? "focus" : "member",
122
+ }));
123
+ const nodeLabelById = new Map(nodes.map((node) => [node.id, node.title ?? node.id]));
124
+ const activities = repository.listActivitiesForNodeIds(Array.from(scopedNodeIdSet), activityLimit);
125
+ const timeline = [
126
+ ...nodes.map((node) => ({
127
+ id: `timeline-node:${node.id}`,
128
+ kind: "node_created",
129
+ at: node.createdAt,
130
+ nodeId: node.id,
131
+ label: `${node.title ?? node.id} created`,
132
+ })),
133
+ ...canonicalEdges.map((edge) => ({
134
+ id: `timeline-edge:${edge.id}`,
135
+ kind: "relation_created",
136
+ at: edge.createdAt,
137
+ edgeId: edge.id,
138
+ nodeId: edge.fromNodeId,
139
+ label: `${nodeLabelById.get(edge.fromNodeId) ?? edge.fromNodeId} ${relationLabel(edge.relationType)} ${nodeLabelById.get(edge.toNodeId) ?? edge.toNodeId}`,
140
+ })),
141
+ ...activities.map((activity) => ({
142
+ id: `timeline-activity:${activity.id}`,
143
+ kind: "activity",
144
+ at: activity.createdAt,
145
+ nodeId: activity.targetNodeId,
146
+ label: `${activity.activityType.replaceAll("_", " ")} on ${nodeLabelById.get(activity.targetNodeId) ?? activity.targetNodeId}`,
147
+ })),
148
+ ...syntheticFallbackEdges.map((edge) => ({
149
+ id: `timeline-fallback-edge:${edge.id}`,
150
+ kind: "relation_created",
151
+ at: edge.createdAt,
152
+ edgeId: edge.id,
153
+ nodeId: edge.source,
154
+ label: `${nodeLabelById.get(edge.source) ?? edge.source} related to ${nodeLabelById.get(edge.target) ?? edge.target}`,
155
+ })),
156
+ ];
157
+ timeline.sort((left, right) => {
158
+ const timeDelta = left.at.localeCompare(right.at);
159
+ if (timeDelta !== 0) {
160
+ return timeDelta;
161
+ }
162
+ const kindRank = kindOrder(left.kind) - kindOrder(right.kind);
163
+ if (kindRank !== 0) {
164
+ return kindRank;
165
+ }
166
+ return left.id.localeCompare(right.id);
167
+ });
168
+ const timeRange = timeline.length
169
+ ? {
170
+ start: timeline[0]?.at ?? null,
171
+ end: timeline[timeline.length - 1]?.at ?? null,
172
+ }
173
+ : {
174
+ start: project.createdAt,
175
+ end: project.createdAt,
176
+ };
177
+ return {
178
+ nodes,
179
+ edges: allEdges,
180
+ timeline,
181
+ meta: {
182
+ focusProjectId: projectId,
183
+ nodeCount: nodes.length,
184
+ edgeCount: allEdges.length,
185
+ inferredEdgeCount: inferredEdges.length + syntheticFallbackEdges.length,
186
+ timeRange,
187
+ },
188
+ };
189
+ }
190
+ function kindOrder(kind) {
191
+ switch (kind) {
192
+ case "node_created":
193
+ return 0;
194
+ case "relation_created":
195
+ return 1;
196
+ default:
197
+ return 2;
198
+ }
199
+ }