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.
- package/README.md +205 -0
- package/app/cli/bin/recallx-mcp.js +2 -0
- package/app/cli/bin/recallx.js +8 -0
- package/app/cli/src/cli.js +808 -0
- package/app/cli/src/format.js +242 -0
- package/app/cli/src/http.js +35 -0
- package/app/mcp/api-client.js +101 -0
- package/app/mcp/index.js +128 -0
- package/app/mcp/server.js +786 -0
- package/app/server/app.js +2263 -0
- package/app/server/config.js +27 -0
- package/app/server/db.js +399 -0
- package/app/server/errors.js +17 -0
- package/app/server/governance.js +466 -0
- package/app/server/index.js +26 -0
- package/app/server/inferred-relations.js +247 -0
- package/app/server/observability.js +495 -0
- package/app/server/project-graph.js +199 -0
- package/app/server/relation-scoring.js +59 -0
- package/app/server/repositories.js +2992 -0
- package/app/server/retrieval.js +486 -0
- package/app/server/semantic/chunker.js +85 -0
- package/app/server/semantic/provider.js +124 -0
- package/app/server/semantic/types.js +1 -0
- package/app/server/semantic/vector-store.js +169 -0
- package/app/server/utils.js +43 -0
- package/app/server/workspace-session.js +128 -0
- package/app/server/workspace.js +79 -0
- package/app/shared/contracts.js +268 -0
- package/app/shared/request-runtime.js +30 -0
- package/app/shared/types.js +1 -0
- package/app/shared/version.js +1 -0
- package/dist/renderer/assets/ProjectGraphCanvas-BMvz9DmE.js +312 -0
- package/dist/renderer/assets/index-C2-KXqBO.css +1 -0
- package/dist/renderer/assets/index-CrDu22h7.js +76 -0
- package/dist/renderer/index.html +13 -0
- 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
|
+
}
|