@jefuriiij/synthra 0.1.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/LICENSE +21 -0
- package/README.md +179 -0
- package/ROADMAP.md +324 -0
- package/bin/syn +8 -0
- package/dist/cli/index.js +4284 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/dashboard/index.js +602 -0
- package/dist/dashboard/index.js.map +1 -0
- package/dist/server/index.js +2933 -0
- package/dist/server/index.js.map +1 -0
- package/package.json +63 -0
|
@@ -0,0 +1,2933 @@
|
|
|
1
|
+
// src/server/http.ts
|
|
2
|
+
import { serve } from "@hono/node-server";
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
import { writeFile as writeFile6 } from "fs/promises";
|
|
5
|
+
|
|
6
|
+
// src/activity/activity-log.ts
|
|
7
|
+
import { appendFile, mkdir } from "fs/promises";
|
|
8
|
+
import { dirname } from "path";
|
|
9
|
+
var DEFAULT_RING_SIZE = 100;
|
|
10
|
+
var ActivityStore = class {
|
|
11
|
+
ring = [];
|
|
12
|
+
maxRingSize;
|
|
13
|
+
persistPath;
|
|
14
|
+
constructor(persistPath, maxRingSize = DEFAULT_RING_SIZE) {
|
|
15
|
+
this.persistPath = persistPath;
|
|
16
|
+
this.maxRingSize = maxRingSize;
|
|
17
|
+
}
|
|
18
|
+
async add(event) {
|
|
19
|
+
this.ring.push(event);
|
|
20
|
+
while (this.ring.length > this.maxRingSize) this.ring.shift();
|
|
21
|
+
await this.persist(event);
|
|
22
|
+
}
|
|
23
|
+
/** Get events newer than `sinceMs` (epoch ms). If omitted, returns the full ring. */
|
|
24
|
+
getEvents(sinceMs) {
|
|
25
|
+
if (!sinceMs || !Number.isFinite(sinceMs)) return this.ring.slice();
|
|
26
|
+
const cutoff = new Date(sinceMs).toISOString();
|
|
27
|
+
return this.ring.filter((e) => e.ts >= cutoff);
|
|
28
|
+
}
|
|
29
|
+
/** Project-relative file paths that have a save/create event newer than `maxAgeMs` ms ago. */
|
|
30
|
+
recentFilePaths(maxAgeMs) {
|
|
31
|
+
const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
|
|
32
|
+
const out = /* @__PURE__ */ new Set();
|
|
33
|
+
for (const e of this.ring) {
|
|
34
|
+
if ("path" in e && (e.kind === "save" || e.kind === "create") && e.ts >= cutoff) {
|
|
35
|
+
out.add(e.path);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return Array.from(out);
|
|
39
|
+
}
|
|
40
|
+
size() {
|
|
41
|
+
return this.ring.length;
|
|
42
|
+
}
|
|
43
|
+
async persist(event) {
|
|
44
|
+
try {
|
|
45
|
+
await mkdir(dirname(this.persistPath), { recursive: true });
|
|
46
|
+
await appendFile(this.persistPath, JSON.stringify(event) + "\n", "utf8");
|
|
47
|
+
} catch {
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// src/activity/file-watcher.ts
|
|
53
|
+
import chokidar from "chokidar";
|
|
54
|
+
import { readFile } from "fs/promises";
|
|
55
|
+
import { join, relative, sep } from "path";
|
|
56
|
+
import ignore from "ignore";
|
|
57
|
+
|
|
58
|
+
// src/shared/logger.ts
|
|
59
|
+
var LEVEL_PRIORITY = {
|
|
60
|
+
debug: 10,
|
|
61
|
+
info: 20,
|
|
62
|
+
warn: 30,
|
|
63
|
+
error: 40
|
|
64
|
+
};
|
|
65
|
+
var activeLevel = process.env.SYN_LOG_LEVEL ?? "info";
|
|
66
|
+
function shouldLog(level) {
|
|
67
|
+
return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[activeLevel];
|
|
68
|
+
}
|
|
69
|
+
function emit(level, msg, ...args) {
|
|
70
|
+
if (!shouldLog(level)) return;
|
|
71
|
+
const stream = level === "error" || level === "warn" ? process.stderr : process.stdout;
|
|
72
|
+
stream.write(`[syn] ${msg}${args.length ? " " + args.map(String).join(" ") : ""}
|
|
73
|
+
`);
|
|
74
|
+
}
|
|
75
|
+
var log = {
|
|
76
|
+
debug: (m, ...a) => emit("debug", m, ...a),
|
|
77
|
+
info: (m, ...a) => emit("info", m, ...a),
|
|
78
|
+
warn: (m, ...a) => emit("warn", m, ...a),
|
|
79
|
+
error: (m, ...a) => emit("error", m, ...a)
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// src/activity/file-watcher.ts
|
|
83
|
+
var ALWAYS_IGNORE = [
|
|
84
|
+
".git",
|
|
85
|
+
".synthra",
|
|
86
|
+
".synthra-graph",
|
|
87
|
+
".claude",
|
|
88
|
+
"node_modules",
|
|
89
|
+
"dist",
|
|
90
|
+
"build",
|
|
91
|
+
"out",
|
|
92
|
+
"coverage",
|
|
93
|
+
".next",
|
|
94
|
+
".nuxt",
|
|
95
|
+
".svelte-kit",
|
|
96
|
+
".turbo",
|
|
97
|
+
".cache",
|
|
98
|
+
".vscode",
|
|
99
|
+
".idea"
|
|
100
|
+
];
|
|
101
|
+
async function readIgnoreFile(path) {
|
|
102
|
+
try {
|
|
103
|
+
const text = await readFile(path, "utf8");
|
|
104
|
+
return text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("#"));
|
|
105
|
+
} catch {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function buildMatcher(root) {
|
|
110
|
+
const ig = ignore();
|
|
111
|
+
ig.add(ALWAYS_IGNORE.map((d) => `${d}/`));
|
|
112
|
+
ig.add(await readIgnoreFile(join(root, ".gitignore")));
|
|
113
|
+
ig.add(await readIgnoreFile(join(root, ".synthraignore")));
|
|
114
|
+
return ig;
|
|
115
|
+
}
|
|
116
|
+
function toPosixRel(root, abs) {
|
|
117
|
+
const rel = relative(root, abs);
|
|
118
|
+
return sep === "/" ? rel : rel.split(sep).join("/");
|
|
119
|
+
}
|
|
120
|
+
function createFileWatcher(root, onEvent) {
|
|
121
|
+
let watcher = null;
|
|
122
|
+
let ig = null;
|
|
123
|
+
const emit2 = async (kind, abs) => {
|
|
124
|
+
if (!ig) return;
|
|
125
|
+
const rel = toPosixRel(root, abs);
|
|
126
|
+
if (!rel || rel.startsWith("..")) return;
|
|
127
|
+
if (ig.ignores(rel)) return;
|
|
128
|
+
try {
|
|
129
|
+
await onEvent({ kind, path: rel, ts: (/* @__PURE__ */ new Date()).toISOString() });
|
|
130
|
+
} catch {
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
return {
|
|
134
|
+
async start() {
|
|
135
|
+
ig = await buildMatcher(root);
|
|
136
|
+
watcher = chokidar.watch(root, {
|
|
137
|
+
// Cross-platform glob ignore. We match both the directory itself and
|
|
138
|
+
// anything inside it. picomatch (chokidar's matcher) normalizes path
|
|
139
|
+
// separators so a single set of forward-slash globs handles
|
|
140
|
+
// Windows + POSIX. Function-based ignore was unreliable on Windows
|
|
141
|
+
// and let chokidar descend into .git/, which crashed on transient
|
|
142
|
+
// index.lock files held exclusively by git.
|
|
143
|
+
ignored: ALWAYS_IGNORE.flatMap((d) => [`**/${d}`, `**/${d}/**`]),
|
|
144
|
+
ignoreInitial: true,
|
|
145
|
+
persistent: true,
|
|
146
|
+
awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50 }
|
|
147
|
+
});
|
|
148
|
+
watcher.on("error", (err2) => {
|
|
149
|
+
const e = err2;
|
|
150
|
+
log.debug(`file watcher error (swallowed): ${e?.code ?? ""} ${e?.message ?? String(err2)}`);
|
|
151
|
+
});
|
|
152
|
+
watcher.on("add", (path) => emit2("create", path));
|
|
153
|
+
watcher.on("change", (path) => emit2("save", path));
|
|
154
|
+
watcher.on("unlink", (path) => emit2("delete", path));
|
|
155
|
+
},
|
|
156
|
+
async stop() {
|
|
157
|
+
if (watcher) {
|
|
158
|
+
await watcher.close();
|
|
159
|
+
watcher = null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// src/activity/git-watcher.ts
|
|
166
|
+
import { execFile } from "child_process";
|
|
167
|
+
import { watch } from "fs";
|
|
168
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
169
|
+
import { join as join2 } from "path";
|
|
170
|
+
import { promisify } from "util";
|
|
171
|
+
var execFileAsync = promisify(execFile);
|
|
172
|
+
var POLL_MS = 2e3;
|
|
173
|
+
async function readHeadBranch(projectRoot) {
|
|
174
|
+
try {
|
|
175
|
+
const head = await readFile2(join2(projectRoot, ".git", "HEAD"), "utf8");
|
|
176
|
+
const m = head.trim().match(/^ref:\s+refs\/heads\/(.+)$/);
|
|
177
|
+
return m?.[1] ?? null;
|
|
178
|
+
} catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async function readStatusPorcelain(projectRoot) {
|
|
183
|
+
try {
|
|
184
|
+
const { stdout } = await execFileAsync("git", ["status", "--porcelain"], {
|
|
185
|
+
cwd: projectRoot
|
|
186
|
+
});
|
|
187
|
+
return stdout;
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function createGitWatcher(root, onEvent) {
|
|
193
|
+
let headWatcher = null;
|
|
194
|
+
let pollTimer = null;
|
|
195
|
+
let lastBranch = null;
|
|
196
|
+
let lastStatus = null;
|
|
197
|
+
const emitSafe = async (event) => {
|
|
198
|
+
try {
|
|
199
|
+
await onEvent(event);
|
|
200
|
+
} catch {
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
const checkHead = async () => {
|
|
204
|
+
const branch = await readHeadBranch(root);
|
|
205
|
+
if (branch && branch !== lastBranch) {
|
|
206
|
+
const prev = lastBranch;
|
|
207
|
+
lastBranch = branch;
|
|
208
|
+
if (prev !== null) {
|
|
209
|
+
await emitSafe({
|
|
210
|
+
kind: "branch-switch",
|
|
211
|
+
details: { from: prev, to: branch },
|
|
212
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
const pollStatus = async () => {
|
|
218
|
+
const status = await readStatusPorcelain(root);
|
|
219
|
+
if (status === null) return;
|
|
220
|
+
if (lastStatus !== null && status !== lastStatus) {
|
|
221
|
+
const prevFiles = parseStatusFiles(lastStatus);
|
|
222
|
+
const nowFiles = parseStatusFiles(status);
|
|
223
|
+
const added = nowFiles.filter((f) => !prevFiles.includes(f));
|
|
224
|
+
const removed = prevFiles.filter((f) => !nowFiles.includes(f));
|
|
225
|
+
await emitSafe({
|
|
226
|
+
kind: "diff-change",
|
|
227
|
+
details: {
|
|
228
|
+
changed_count: nowFiles.length,
|
|
229
|
+
newly_dirty: added,
|
|
230
|
+
newly_clean: removed
|
|
231
|
+
},
|
|
232
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
lastStatus = status;
|
|
236
|
+
};
|
|
237
|
+
return {
|
|
238
|
+
async start() {
|
|
239
|
+
lastBranch = await readHeadBranch(root);
|
|
240
|
+
lastStatus = await readStatusPorcelain(root);
|
|
241
|
+
try {
|
|
242
|
+
headWatcher = watch(join2(root, ".git", "HEAD"), () => {
|
|
243
|
+
void checkHead();
|
|
244
|
+
});
|
|
245
|
+
headWatcher.on("error", () => {
|
|
246
|
+
});
|
|
247
|
+
} catch {
|
|
248
|
+
}
|
|
249
|
+
pollTimer = setInterval(() => {
|
|
250
|
+
void pollStatus();
|
|
251
|
+
}, POLL_MS);
|
|
252
|
+
pollTimer.unref?.();
|
|
253
|
+
},
|
|
254
|
+
async stop() {
|
|
255
|
+
if (headWatcher) {
|
|
256
|
+
headWatcher.close();
|
|
257
|
+
headWatcher = null;
|
|
258
|
+
}
|
|
259
|
+
if (pollTimer) {
|
|
260
|
+
clearInterval(pollTimer);
|
|
261
|
+
pollTimer = null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function parseStatusFiles(porcelain) {
|
|
267
|
+
return porcelain.split(/\r?\n/).map((l) => l.slice(3).trim()).filter((l) => l.length > 0);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// src/cli/scan-command.ts
|
|
271
|
+
import { resolve } from "path";
|
|
272
|
+
|
|
273
|
+
// src/scanner/extract.ts
|
|
274
|
+
import { dirname as dirname2, join as join3, posix } from "path";
|
|
275
|
+
|
|
276
|
+
// src/scanner/hash.ts
|
|
277
|
+
import { createHash } from "crypto";
|
|
278
|
+
function fileHash(content) {
|
|
279
|
+
return createHash("sha1").update(content).digest("hex").slice(0, 8);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// src/scanner/keywords.ts
|
|
283
|
+
var STOPWORDS = /* @__PURE__ */ new Set([
|
|
284
|
+
"a",
|
|
285
|
+
"an",
|
|
286
|
+
"and",
|
|
287
|
+
"are",
|
|
288
|
+
"as",
|
|
289
|
+
"at",
|
|
290
|
+
"be",
|
|
291
|
+
"but",
|
|
292
|
+
"by",
|
|
293
|
+
"do",
|
|
294
|
+
"for",
|
|
295
|
+
"from",
|
|
296
|
+
"has",
|
|
297
|
+
"have",
|
|
298
|
+
"he",
|
|
299
|
+
"if",
|
|
300
|
+
"in",
|
|
301
|
+
"is",
|
|
302
|
+
"it",
|
|
303
|
+
"its",
|
|
304
|
+
"not",
|
|
305
|
+
"of",
|
|
306
|
+
"on",
|
|
307
|
+
"or",
|
|
308
|
+
"she",
|
|
309
|
+
"that",
|
|
310
|
+
"the",
|
|
311
|
+
"they",
|
|
312
|
+
"this",
|
|
313
|
+
"to",
|
|
314
|
+
"was",
|
|
315
|
+
"we",
|
|
316
|
+
"were",
|
|
317
|
+
"will",
|
|
318
|
+
"with",
|
|
319
|
+
"you",
|
|
320
|
+
"your",
|
|
321
|
+
"i",
|
|
322
|
+
"me",
|
|
323
|
+
"my",
|
|
324
|
+
"our",
|
|
325
|
+
"us",
|
|
326
|
+
"their",
|
|
327
|
+
"them",
|
|
328
|
+
"his",
|
|
329
|
+
"her",
|
|
330
|
+
// common code words that add no signal
|
|
331
|
+
"function",
|
|
332
|
+
"const",
|
|
333
|
+
"let",
|
|
334
|
+
"var",
|
|
335
|
+
"class",
|
|
336
|
+
"interface",
|
|
337
|
+
"type",
|
|
338
|
+
"enum",
|
|
339
|
+
"import",
|
|
340
|
+
"export",
|
|
341
|
+
"from",
|
|
342
|
+
"default",
|
|
343
|
+
"return",
|
|
344
|
+
"if",
|
|
345
|
+
"else",
|
|
346
|
+
"for",
|
|
347
|
+
"while",
|
|
348
|
+
"do",
|
|
349
|
+
"switch",
|
|
350
|
+
"case",
|
|
351
|
+
"break",
|
|
352
|
+
"continue",
|
|
353
|
+
"new",
|
|
354
|
+
"this",
|
|
355
|
+
"super",
|
|
356
|
+
"throw",
|
|
357
|
+
"try",
|
|
358
|
+
"catch",
|
|
359
|
+
"finally",
|
|
360
|
+
"async",
|
|
361
|
+
"await",
|
|
362
|
+
"yield",
|
|
363
|
+
"true",
|
|
364
|
+
"false",
|
|
365
|
+
"null",
|
|
366
|
+
"undefined",
|
|
367
|
+
"void",
|
|
368
|
+
"any",
|
|
369
|
+
"string",
|
|
370
|
+
"number",
|
|
371
|
+
"boolean",
|
|
372
|
+
"object",
|
|
373
|
+
"array",
|
|
374
|
+
"self",
|
|
375
|
+
"cls",
|
|
376
|
+
"def",
|
|
377
|
+
"lambda",
|
|
378
|
+
"pass",
|
|
379
|
+
"raise",
|
|
380
|
+
"with",
|
|
381
|
+
"as",
|
|
382
|
+
"in",
|
|
383
|
+
"todo",
|
|
384
|
+
"fixme",
|
|
385
|
+
"note"
|
|
386
|
+
]);
|
|
387
|
+
var COMMON_CODE = /* @__PURE__ */ new Set([
|
|
388
|
+
"value",
|
|
389
|
+
"data",
|
|
390
|
+
"result",
|
|
391
|
+
"args",
|
|
392
|
+
"kwargs",
|
|
393
|
+
"options",
|
|
394
|
+
"config",
|
|
395
|
+
"params",
|
|
396
|
+
"name",
|
|
397
|
+
"id",
|
|
398
|
+
"key",
|
|
399
|
+
"index",
|
|
400
|
+
"item",
|
|
401
|
+
"items",
|
|
402
|
+
"list",
|
|
403
|
+
"map",
|
|
404
|
+
"set",
|
|
405
|
+
"get",
|
|
406
|
+
"set",
|
|
407
|
+
"add",
|
|
408
|
+
"remove",
|
|
409
|
+
"delete",
|
|
410
|
+
"create",
|
|
411
|
+
"update",
|
|
412
|
+
"find",
|
|
413
|
+
"fetch",
|
|
414
|
+
"load",
|
|
415
|
+
"save",
|
|
416
|
+
"init",
|
|
417
|
+
"main",
|
|
418
|
+
"run",
|
|
419
|
+
"start",
|
|
420
|
+
"stop",
|
|
421
|
+
"test",
|
|
422
|
+
"check",
|
|
423
|
+
"validate",
|
|
424
|
+
"error",
|
|
425
|
+
"err",
|
|
426
|
+
"warn",
|
|
427
|
+
"info",
|
|
428
|
+
"debug",
|
|
429
|
+
"log",
|
|
430
|
+
"trace",
|
|
431
|
+
"msg",
|
|
432
|
+
"message",
|
|
433
|
+
"path",
|
|
434
|
+
"file",
|
|
435
|
+
"dir",
|
|
436
|
+
"url",
|
|
437
|
+
"host",
|
|
438
|
+
"port",
|
|
439
|
+
"size",
|
|
440
|
+
"length",
|
|
441
|
+
"count",
|
|
442
|
+
"input",
|
|
443
|
+
"output",
|
|
444
|
+
"source",
|
|
445
|
+
"target",
|
|
446
|
+
"callback",
|
|
447
|
+
"handler",
|
|
448
|
+
"listener",
|
|
449
|
+
"props",
|
|
450
|
+
"state",
|
|
451
|
+
"context",
|
|
452
|
+
"render",
|
|
453
|
+
"component",
|
|
454
|
+
"node",
|
|
455
|
+
"tree",
|
|
456
|
+
"root"
|
|
457
|
+
]);
|
|
458
|
+
function score(token) {
|
|
459
|
+
if (STOPWORDS.has(token)) return 0;
|
|
460
|
+
if (COMMON_CODE.has(token)) return 0.2;
|
|
461
|
+
if (token.length <= 2) return 0.1;
|
|
462
|
+
return 1;
|
|
463
|
+
}
|
|
464
|
+
function splitIdentifier(id) {
|
|
465
|
+
const partsRaw = id.split(/[_\-./]+/).filter(Boolean);
|
|
466
|
+
const out = [];
|
|
467
|
+
for (const part of partsRaw) {
|
|
468
|
+
const camelParts = part.match(/[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|[0-9]+/g);
|
|
469
|
+
if (camelParts) out.push(...camelParts);
|
|
470
|
+
else out.push(part);
|
|
471
|
+
}
|
|
472
|
+
return out.map((w) => w.toLowerCase()).filter((w) => /[a-z]/.test(w));
|
|
473
|
+
}
|
|
474
|
+
function extractKeywords(content, _ext) {
|
|
475
|
+
const tokens = content.match(/[A-Za-z_][A-Za-z0-9_]{1,40}/g) ?? [];
|
|
476
|
+
const counts = /* @__PURE__ */ new Map();
|
|
477
|
+
for (const tok of tokens) {
|
|
478
|
+
for (const word of splitIdentifier(tok)) {
|
|
479
|
+
const w = score(word);
|
|
480
|
+
if (w === 0) continue;
|
|
481
|
+
counts.set(word, (counts.get(word) ?? 0) + w);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return Array.from(counts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 32).map(([w]) => w);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// src/scanner/extract.ts
|
|
488
|
+
var RESOLVE_EXTS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".svelte", ".vue"];
|
|
489
|
+
var INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.jsx", "__init__.py"];
|
|
490
|
+
function fileId(relPath) {
|
|
491
|
+
return `file:${relPath}`;
|
|
492
|
+
}
|
|
493
|
+
function symbolId(relPath, sym) {
|
|
494
|
+
return `symbol:${relPath}::${sym.name}:${sym.startLine}`;
|
|
495
|
+
}
|
|
496
|
+
function toFileNode(parsed) {
|
|
497
|
+
const content = parsed.source;
|
|
498
|
+
return {
|
|
499
|
+
id: fileId(parsed.file.relPath),
|
|
500
|
+
kind: "file",
|
|
501
|
+
path: parsed.file.relPath,
|
|
502
|
+
ext: parsed.file.ext,
|
|
503
|
+
size: parsed.file.size,
|
|
504
|
+
keywords: extractKeywords(content, parsed.file.ext),
|
|
505
|
+
content,
|
|
506
|
+
summary: extractSummary(content),
|
|
507
|
+
file_hash: fileHash(content)
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
function extractSummary(content) {
|
|
511
|
+
const trimmed = content.replace(/^\s+/, "");
|
|
512
|
+
const slashMatch = trimmed.match(/^\/\/\s?(.*(?:\r?\n\/\/\s?.*)*)/);
|
|
513
|
+
if (slashMatch?.[1]) return slashMatch[1].split(/\r?\n/).join(" ").trim().slice(0, 200);
|
|
514
|
+
const blockMatch = trimmed.match(/^\/\*\*?([\s\S]*?)\*\//);
|
|
515
|
+
if (blockMatch?.[1]) {
|
|
516
|
+
return blockMatch[1].split(/\r?\n/).map((l) => l.replace(/^\s*\*\s?/, "")).join(" ").trim().slice(0, 200);
|
|
517
|
+
}
|
|
518
|
+
const hashMatch = trimmed.match(/^#\s?(.*(?:\r?\n#\s?.*)*)/);
|
|
519
|
+
if (hashMatch?.[1]) return hashMatch[1].split(/\r?\n/).join(" ").trim().slice(0, 200);
|
|
520
|
+
return "";
|
|
521
|
+
}
|
|
522
|
+
function toSymbolNode(parsed, sym) {
|
|
523
|
+
return {
|
|
524
|
+
id: symbolId(parsed.file.relPath, sym),
|
|
525
|
+
kind: "symbol",
|
|
526
|
+
symbol_kind: sym.kind,
|
|
527
|
+
name: sym.name,
|
|
528
|
+
file: parsed.file.relPath,
|
|
529
|
+
start_line: sym.startLine,
|
|
530
|
+
end_line: sym.endLine,
|
|
531
|
+
signature: sym.signature
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
var REWRITE_EXT_RE = /\.(js|jsx|mjs|cjs)$/;
|
|
535
|
+
function resolveImport(fromRelPath, spec, filesByPath) {
|
|
536
|
+
if (!spec.startsWith(".")) return null;
|
|
537
|
+
const fromDir = posix.dirname(toPosix(fromRelPath));
|
|
538
|
+
const base = posix.normalize(posix.join(fromDir, toPosix(spec)));
|
|
539
|
+
const candidates = [base];
|
|
540
|
+
const rewritten = base.replace(REWRITE_EXT_RE, "");
|
|
541
|
+
if (rewritten !== base) candidates.push(rewritten);
|
|
542
|
+
for (const c of candidates) {
|
|
543
|
+
if (filesByPath.has(c)) return c;
|
|
544
|
+
for (const ext of RESOLVE_EXTS) {
|
|
545
|
+
if (filesByPath.has(c + ext)) return c + ext;
|
|
546
|
+
}
|
|
547
|
+
for (const idx of INDEX_FILES) {
|
|
548
|
+
const candidate = posix.join(c, idx);
|
|
549
|
+
if (filesByPath.has(candidate)) return candidate;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
function toPosix(p) {
|
|
555
|
+
return p.split(/[\\/]/).join("/");
|
|
556
|
+
}
|
|
557
|
+
var TEST_RE = /^(?<base>.+?)\.(test|spec)\.(?<ext>[tj]sx?|py)$/;
|
|
558
|
+
function testTarget(relPath, filesByPath) {
|
|
559
|
+
const fileName = relPath.split("/").pop() ?? relPath;
|
|
560
|
+
const match = TEST_RE.exec(fileName);
|
|
561
|
+
if (!match) return null;
|
|
562
|
+
const dir = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/") + 1) : "";
|
|
563
|
+
const base = match.groups?.base ?? "";
|
|
564
|
+
const ext = match.groups?.ext ?? "";
|
|
565
|
+
if (!base || !ext) return null;
|
|
566
|
+
const candidate = `${dir}${base}.${ext}`;
|
|
567
|
+
if (filesByPath.has(candidate)) return candidate;
|
|
568
|
+
for (const e of RESOLVE_EXTS) {
|
|
569
|
+
const alt = `${dir}${base}${e}`;
|
|
570
|
+
if (filesByPath.has(alt)) return alt;
|
|
571
|
+
}
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
async function buildGraph(root, parsed) {
|
|
575
|
+
const filesByPath = /* @__PURE__ */ new Map();
|
|
576
|
+
for (const p of parsed) filesByPath.set(p.file.relPath, true);
|
|
577
|
+
const nodes = [];
|
|
578
|
+
const edges = [];
|
|
579
|
+
for (const p of parsed) {
|
|
580
|
+
const fileNode = toFileNode(p);
|
|
581
|
+
nodes.push(fileNode);
|
|
582
|
+
for (const sym of p.symbols) {
|
|
583
|
+
const symNode = toSymbolNode(p, sym);
|
|
584
|
+
nodes.push(symNode);
|
|
585
|
+
edges.push({ from: fileNode.id, to: symNode.id, kind: "defines" });
|
|
586
|
+
}
|
|
587
|
+
const importEdges = /* @__PURE__ */ new Set();
|
|
588
|
+
for (const spec of p.imports) {
|
|
589
|
+
const target = resolveImport(p.file.relPath, spec, filesByPath);
|
|
590
|
+
if (!target) continue;
|
|
591
|
+
const key = `${fileNode.id}->${fileId(target)}`;
|
|
592
|
+
if (importEdges.has(key)) continue;
|
|
593
|
+
importEdges.add(key);
|
|
594
|
+
edges.push({ from: fileNode.id, to: fileId(target), kind: "imports" });
|
|
595
|
+
}
|
|
596
|
+
const testTargetPath = testTarget(p.file.relPath, filesByPath);
|
|
597
|
+
if (testTargetPath && testTargetPath !== p.file.relPath) {
|
|
598
|
+
edges.push({ from: fileNode.id, to: fileId(testTargetPath), kind: "tests" });
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
const symbolCount = nodes.filter((n) => n.kind === "symbol").length;
|
|
602
|
+
const fileCount = nodes.length - symbolCount;
|
|
603
|
+
return {
|
|
604
|
+
root,
|
|
605
|
+
node_count: nodes.length,
|
|
606
|
+
edge_count: edges.length,
|
|
607
|
+
file_count: fileCount,
|
|
608
|
+
symbol_count: symbolCount,
|
|
609
|
+
nodes,
|
|
610
|
+
edges,
|
|
611
|
+
generated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
612
|
+
schema_version: 1
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
function buildSymbolIndex(graph) {
|
|
616
|
+
const out = {};
|
|
617
|
+
for (const node of graph.nodes) {
|
|
618
|
+
if (node.kind !== "symbol") continue;
|
|
619
|
+
const list = out[node.name] ?? (out[node.name] = []);
|
|
620
|
+
list.push({ file: node.file, line: node.start_line, kind: node.symbol_kind });
|
|
621
|
+
}
|
|
622
|
+
return out;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// src/scanner/parser.ts
|
|
626
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
627
|
+
import { createRequire } from "module";
|
|
628
|
+
import Parser from "web-tree-sitter";
|
|
629
|
+
|
|
630
|
+
// src/scanner/parsers/_generic.ts
|
|
631
|
+
function firstLine(text, max = 200) {
|
|
632
|
+
const line = text.split(/\r?\n/, 1)[0] ?? "";
|
|
633
|
+
return line.length > max ? line.slice(0, max) + "\u2026" : line;
|
|
634
|
+
}
|
|
635
|
+
function cleanImport(s) {
|
|
636
|
+
return s.replace(/^["'`<]+|["'`>]+$/g, "").trim();
|
|
637
|
+
}
|
|
638
|
+
async function runGenericParser(config, f, source) {
|
|
639
|
+
let symbols = [];
|
|
640
|
+
let imports = [];
|
|
641
|
+
try {
|
|
642
|
+
const { parser, language } = await createParser(config.grammar);
|
|
643
|
+
const tree = parser.parse(source);
|
|
644
|
+
if (!tree) return { file: f, source, symbols, imports, calls: [] };
|
|
645
|
+
const query = language.query(config.query);
|
|
646
|
+
const matches = query.matches(tree.rootNode);
|
|
647
|
+
for (const match of matches) {
|
|
648
|
+
const byName = /* @__PURE__ */ new Map();
|
|
649
|
+
for (const cap of match.captures) byName.set(cap.name, cap.node);
|
|
650
|
+
let matched = null;
|
|
651
|
+
for (const d of config.decls) {
|
|
652
|
+
if (byName.has(d.declCapture) && byName.has(d.nameCapture)) {
|
|
653
|
+
matched = d;
|
|
654
|
+
break;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
if (matched) {
|
|
658
|
+
const declNode = byName.get(matched.declCapture);
|
|
659
|
+
const nameNode = byName.get(matched.nameCapture);
|
|
660
|
+
symbols.push({
|
|
661
|
+
name: nameNode.text,
|
|
662
|
+
kind: matched.kind,
|
|
663
|
+
startLine: declNode.startPosition.row + 1,
|
|
664
|
+
endLine: declNode.endPosition.row + 1,
|
|
665
|
+
signature: firstLine(declNode.text)
|
|
666
|
+
});
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
if (config.importCapture) {
|
|
670
|
+
const imp = byName.get(config.importCapture);
|
|
671
|
+
if (imp) imports.push(cleanImport(imp.text));
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
const seen = /* @__PURE__ */ new Set();
|
|
675
|
+
symbols = symbols.filter((s) => {
|
|
676
|
+
const k = `${s.name}:${s.startLine}`;
|
|
677
|
+
if (seen.has(k)) return false;
|
|
678
|
+
seen.add(k);
|
|
679
|
+
return true;
|
|
680
|
+
});
|
|
681
|
+
imports = Array.from(new Set(imports)).filter((s) => s.length > 0);
|
|
682
|
+
} catch {
|
|
683
|
+
}
|
|
684
|
+
return { file: f, source, symbols, imports, calls: [] };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// src/scanner/parsers/c.ts
|
|
688
|
+
var QUERY = `
|
|
689
|
+
(function_definition declarator: (function_declarator declarator: (identifier) @function.name)) @function
|
|
690
|
+
(struct_specifier name: (type_identifier) @struct.name) @struct
|
|
691
|
+
(enum_specifier name: (type_identifier) @enum.name) @enum
|
|
692
|
+
(type_definition declarator: (type_identifier) @type.name) @type
|
|
693
|
+
(preproc_include path: (string_literal) @import)
|
|
694
|
+
(preproc_include path: (system_lib_string) @import)
|
|
695
|
+
`;
|
|
696
|
+
async function parseC(f, source) {
|
|
697
|
+
return runGenericParser(
|
|
698
|
+
{
|
|
699
|
+
grammar: "c",
|
|
700
|
+
query: QUERY,
|
|
701
|
+
decls: [
|
|
702
|
+
{ declCapture: "function", nameCapture: "function.name", kind: "function" },
|
|
703
|
+
{ declCapture: "struct", nameCapture: "struct.name", kind: "class" },
|
|
704
|
+
{ declCapture: "enum", nameCapture: "enum.name", kind: "enum" },
|
|
705
|
+
{ declCapture: "type", nameCapture: "type.name", kind: "type" }
|
|
706
|
+
],
|
|
707
|
+
importCapture: "import"
|
|
708
|
+
},
|
|
709
|
+
f,
|
|
710
|
+
source
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// src/scanner/parsers/cpp.ts
|
|
715
|
+
var QUERY2 = `
|
|
716
|
+
(function_definition declarator: (function_declarator declarator: (identifier) @function.name)) @function
|
|
717
|
+
(function_definition declarator: (function_declarator declarator: (qualified_identifier) @method.name)) @method
|
|
718
|
+
(class_specifier name: (type_identifier) @class.name) @class
|
|
719
|
+
(struct_specifier name: (type_identifier) @struct.name) @struct
|
|
720
|
+
(enum_specifier name: (type_identifier) @enum.name) @enum
|
|
721
|
+
(namespace_definition name: (namespace_identifier) @namespace.name) @namespace
|
|
722
|
+
(preproc_include path: (string_literal) @import)
|
|
723
|
+
(preproc_include path: (system_lib_string) @import)
|
|
724
|
+
`;
|
|
725
|
+
async function parseCpp(f, source) {
|
|
726
|
+
return runGenericParser(
|
|
727
|
+
{
|
|
728
|
+
grammar: "cpp",
|
|
729
|
+
query: QUERY2,
|
|
730
|
+
decls: [
|
|
731
|
+
{ declCapture: "function", nameCapture: "function.name", kind: "function" },
|
|
732
|
+
{ declCapture: "method", nameCapture: "method.name", kind: "method" },
|
|
733
|
+
{ declCapture: "class", nameCapture: "class.name", kind: "class" },
|
|
734
|
+
{ declCapture: "struct", nameCapture: "struct.name", kind: "class" },
|
|
735
|
+
{ declCapture: "enum", nameCapture: "enum.name", kind: "enum" },
|
|
736
|
+
{ declCapture: "namespace", nameCapture: "namespace.name", kind: "class" }
|
|
737
|
+
],
|
|
738
|
+
importCapture: "import"
|
|
739
|
+
},
|
|
740
|
+
f,
|
|
741
|
+
source
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// src/scanner/parsers/csharp.ts
|
|
746
|
+
var QUERY3 = `
|
|
747
|
+
(class_declaration name: (identifier) @class.name) @class
|
|
748
|
+
(interface_declaration name: (identifier) @interface.name) @interface
|
|
749
|
+
(struct_declaration name: (identifier) @struct.name) @struct
|
|
750
|
+
(enum_declaration name: (identifier) @enum.name) @enum
|
|
751
|
+
(method_declaration name: (identifier) @method.name) @method
|
|
752
|
+
(namespace_declaration name: (_) @namespace.name) @namespace
|
|
753
|
+
(using_directive (_) @import)
|
|
754
|
+
`;
|
|
755
|
+
async function parseCSharp(f, source) {
|
|
756
|
+
return runGenericParser(
|
|
757
|
+
{
|
|
758
|
+
grammar: "csharp",
|
|
759
|
+
query: QUERY3,
|
|
760
|
+
decls: [
|
|
761
|
+
{ declCapture: "class", nameCapture: "class.name", kind: "class" },
|
|
762
|
+
{ declCapture: "interface", nameCapture: "interface.name", kind: "interface" },
|
|
763
|
+
{ declCapture: "struct", nameCapture: "struct.name", kind: "class" },
|
|
764
|
+
{ declCapture: "enum", nameCapture: "enum.name", kind: "enum" },
|
|
765
|
+
{ declCapture: "method", nameCapture: "method.name", kind: "method" },
|
|
766
|
+
{ declCapture: "namespace", nameCapture: "namespace.name", kind: "class" }
|
|
767
|
+
],
|
|
768
|
+
importCapture: "import"
|
|
769
|
+
},
|
|
770
|
+
f,
|
|
771
|
+
source
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// src/scanner/parsers/dart.ts
|
|
776
|
+
var QUERY4 = `
|
|
777
|
+
(class_definition (identifier) @class.name) @class
|
|
778
|
+
(mixin_declaration (identifier) @class.name) @mixin
|
|
779
|
+
(extension_declaration (identifier) @class.name) @ext
|
|
780
|
+
(function_signature (identifier) @function.name) @function
|
|
781
|
+
`;
|
|
782
|
+
async function parseDart(f, source) {
|
|
783
|
+
return runGenericParser(
|
|
784
|
+
{
|
|
785
|
+
grammar: "dart",
|
|
786
|
+
query: QUERY4,
|
|
787
|
+
decls: [
|
|
788
|
+
{ declCapture: "class", nameCapture: "class.name", kind: "class" },
|
|
789
|
+
{ declCapture: "mixin", nameCapture: "class.name", kind: "class" },
|
|
790
|
+
{ declCapture: "ext", nameCapture: "class.name", kind: "class" },
|
|
791
|
+
{ declCapture: "function", nameCapture: "function.name", kind: "function" }
|
|
792
|
+
]
|
|
793
|
+
},
|
|
794
|
+
f,
|
|
795
|
+
source
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// src/scanner/parsers/go.ts
|
|
800
|
+
var QUERY5 = `
|
|
801
|
+
(function_declaration name: (identifier) @function.name) @function
|
|
802
|
+
(method_declaration name: (field_identifier) @method.name) @method
|
|
803
|
+
(type_spec name: (type_identifier) @type.name) @type
|
|
804
|
+
(import_spec path: (interpreted_string_literal) @import)
|
|
805
|
+
`;
|
|
806
|
+
async function parseGo(f, source) {
|
|
807
|
+
return runGenericParser(
|
|
808
|
+
{
|
|
809
|
+
grammar: "go",
|
|
810
|
+
query: QUERY5,
|
|
811
|
+
decls: [
|
|
812
|
+
{ declCapture: "function", nameCapture: "function.name", kind: "function" },
|
|
813
|
+
{ declCapture: "method", nameCapture: "method.name", kind: "method" },
|
|
814
|
+
{ declCapture: "type", nameCapture: "type.name", kind: "type" }
|
|
815
|
+
],
|
|
816
|
+
importCapture: "import"
|
|
817
|
+
},
|
|
818
|
+
f,
|
|
819
|
+
source
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// src/scanner/parsers/java.ts
|
|
824
|
+
var QUERY6 = `
|
|
825
|
+
(class_declaration name: (identifier) @class.name) @class
|
|
826
|
+
(interface_declaration name: (identifier) @interface.name) @interface
|
|
827
|
+
(method_declaration name: (identifier) @method.name) @method
|
|
828
|
+
(enum_declaration name: (identifier) @enum.name) @enum
|
|
829
|
+
(import_declaration (scoped_identifier) @import)
|
|
830
|
+
`;
|
|
831
|
+
async function parseJava(f, source) {
|
|
832
|
+
return runGenericParser(
|
|
833
|
+
{
|
|
834
|
+
grammar: "java",
|
|
835
|
+
query: QUERY6,
|
|
836
|
+
decls: [
|
|
837
|
+
{ declCapture: "class", nameCapture: "class.name", kind: "class" },
|
|
838
|
+
{ declCapture: "interface", nameCapture: "interface.name", kind: "interface" },
|
|
839
|
+
{ declCapture: "method", nameCapture: "method.name", kind: "method" },
|
|
840
|
+
{ declCapture: "enum", nameCapture: "enum.name", kind: "enum" }
|
|
841
|
+
],
|
|
842
|
+
importCapture: "import"
|
|
843
|
+
},
|
|
844
|
+
f,
|
|
845
|
+
source
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// src/scanner/parsers/kotlin.ts
|
|
850
|
+
var QUERY7 = `
|
|
851
|
+
(function_declaration (simple_identifier) @function.name) @function
|
|
852
|
+
(class_declaration (type_identifier) @class.name) @class
|
|
853
|
+
(object_declaration (type_identifier) @object.name) @object
|
|
854
|
+
(import_header (identifier) @import)
|
|
855
|
+
`;
|
|
856
|
+
async function parseKotlin(f, source) {
|
|
857
|
+
return runGenericParser(
|
|
858
|
+
{
|
|
859
|
+
grammar: "kotlin",
|
|
860
|
+
query: QUERY7,
|
|
861
|
+
decls: [
|
|
862
|
+
{ declCapture: "function", nameCapture: "function.name", kind: "function" },
|
|
863
|
+
{ declCapture: "class", nameCapture: "class.name", kind: "class" },
|
|
864
|
+
{ declCapture: "object", nameCapture: "object.name", kind: "class" }
|
|
865
|
+
],
|
|
866
|
+
importCapture: "import"
|
|
867
|
+
},
|
|
868
|
+
f,
|
|
869
|
+
source
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// src/scanner/parsers/php.ts
|
|
874
|
+
var QUERY8 = `
|
|
875
|
+
(function_definition name: (name) @function.name) @function
|
|
876
|
+
(class_declaration name: (name) @class.name) @class
|
|
877
|
+
(interface_declaration name: (name) @interface.name) @interface
|
|
878
|
+
(trait_declaration name: (name) @trait.name) @trait
|
|
879
|
+
(method_declaration name: (name) @method.name) @method
|
|
880
|
+
`;
|
|
881
|
+
async function parsePhp(f, source) {
|
|
882
|
+
return runGenericParser(
|
|
883
|
+
{
|
|
884
|
+
grammar: "php",
|
|
885
|
+
query: QUERY8,
|
|
886
|
+
decls: [
|
|
887
|
+
{ declCapture: "function", nameCapture: "function.name", kind: "function" },
|
|
888
|
+
{ declCapture: "class", nameCapture: "class.name", kind: "class" },
|
|
889
|
+
{ declCapture: "interface", nameCapture: "interface.name", kind: "interface" },
|
|
890
|
+
{ declCapture: "trait", nameCapture: "trait.name", kind: "class" },
|
|
891
|
+
{ declCapture: "method", nameCapture: "method.name", kind: "method" }
|
|
892
|
+
]
|
|
893
|
+
},
|
|
894
|
+
f,
|
|
895
|
+
source
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// src/scanner/parsers/python.ts
|
|
900
|
+
var QUERY9 = `
|
|
901
|
+
(function_definition name: (identifier) @function.name) @function
|
|
902
|
+
(class_definition name: (identifier) @class.name) @class
|
|
903
|
+
(import_statement name: (dotted_name) @import.module)
|
|
904
|
+
(import_from_statement module_name: (dotted_name) @import.from)
|
|
905
|
+
(import_from_statement module_name: (relative_import) @import.from)
|
|
906
|
+
`;
|
|
907
|
+
function firstLine2(text, max = 200) {
|
|
908
|
+
const line = text.split(/\r?\n/, 1)[0] ?? "";
|
|
909
|
+
return line.length > max ? line.slice(0, max) + "\u2026" : line;
|
|
910
|
+
}
|
|
911
|
+
async function parsePython(f, source) {
|
|
912
|
+
let symbols = [];
|
|
913
|
+
let imports = [];
|
|
914
|
+
try {
|
|
915
|
+
const { parser, language } = await createParser("python");
|
|
916
|
+
const tree = parser.parse(source);
|
|
917
|
+
if (!tree) return { file: f, source, symbols, imports, calls: [] };
|
|
918
|
+
const query = language.query(QUERY9);
|
|
919
|
+
const matches = query.matches(tree.rootNode);
|
|
920
|
+
for (const match of matches) {
|
|
921
|
+
const byName = /* @__PURE__ */ new Map();
|
|
922
|
+
for (const cap of match.captures) byName.set(cap.name, cap.node);
|
|
923
|
+
const funcDecl = byName.get("function");
|
|
924
|
+
const funcName = byName.get("function.name");
|
|
925
|
+
if (funcDecl && funcName) {
|
|
926
|
+
const parentType = funcDecl.parent?.parent?.type;
|
|
927
|
+
const isMethod = parentType === "class_definition";
|
|
928
|
+
symbols.push({
|
|
929
|
+
name: funcName.text,
|
|
930
|
+
kind: isMethod ? "method" : "function",
|
|
931
|
+
startLine: funcDecl.startPosition.row + 1,
|
|
932
|
+
endLine: funcDecl.endPosition.row + 1,
|
|
933
|
+
signature: firstLine2(funcDecl.text)
|
|
934
|
+
});
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
const classDecl = byName.get("class");
|
|
938
|
+
const className = byName.get("class.name");
|
|
939
|
+
if (classDecl && className) {
|
|
940
|
+
symbols.push({
|
|
941
|
+
name: className.text,
|
|
942
|
+
kind: "class",
|
|
943
|
+
startLine: classDecl.startPosition.row + 1,
|
|
944
|
+
endLine: classDecl.endPosition.row + 1,
|
|
945
|
+
signature: firstLine2(classDecl.text)
|
|
946
|
+
});
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
const importNode = byName.get("import.module") ?? byName.get("import.from");
|
|
950
|
+
if (importNode) imports.push(importNode.text);
|
|
951
|
+
}
|
|
952
|
+
const seen = /* @__PURE__ */ new Set();
|
|
953
|
+
symbols = symbols.filter((s) => {
|
|
954
|
+
const key = `${s.name}:${s.startLine}`;
|
|
955
|
+
if (seen.has(key)) return false;
|
|
956
|
+
seen.add(key);
|
|
957
|
+
return true;
|
|
958
|
+
});
|
|
959
|
+
imports = Array.from(new Set(imports));
|
|
960
|
+
} catch {
|
|
961
|
+
}
|
|
962
|
+
return { file: f, source, symbols, imports, calls: [] };
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// src/scanner/parsers/ruby.ts
|
|
966
|
+
var QUERY10 = `
|
|
967
|
+
(method name: (identifier) @function.name) @function
|
|
968
|
+
(singleton_method name: (identifier) @method.name) @method
|
|
969
|
+
(class name: (constant) @class.name) @class
|
|
970
|
+
(module name: (constant) @module.name) @module
|
|
971
|
+
`;
|
|
972
|
+
async function parseRuby(f, source) {
|
|
973
|
+
return runGenericParser(
|
|
974
|
+
{
|
|
975
|
+
grammar: "ruby",
|
|
976
|
+
query: QUERY10,
|
|
977
|
+
decls: [
|
|
978
|
+
{ declCapture: "function", nameCapture: "function.name", kind: "function" },
|
|
979
|
+
{ declCapture: "method", nameCapture: "method.name", kind: "method" },
|
|
980
|
+
{ declCapture: "class", nameCapture: "class.name", kind: "class" },
|
|
981
|
+
{ declCapture: "module", nameCapture: "module.name", kind: "class" }
|
|
982
|
+
]
|
|
983
|
+
},
|
|
984
|
+
f,
|
|
985
|
+
source
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// src/scanner/parsers/rust.ts
|
|
990
|
+
var QUERY11 = `
|
|
991
|
+
(function_item name: (identifier) @function.name) @function
|
|
992
|
+
(struct_item name: (type_identifier) @struct.name) @struct
|
|
993
|
+
(enum_item name: (type_identifier) @enum.name) @enum
|
|
994
|
+
(trait_item name: (type_identifier) @trait.name) @trait
|
|
995
|
+
(impl_item type: (type_identifier) @impl.name) @impl
|
|
996
|
+
`;
|
|
997
|
+
async function parseRust(f, source) {
|
|
998
|
+
return runGenericParser(
|
|
999
|
+
{
|
|
1000
|
+
grammar: "rust",
|
|
1001
|
+
query: QUERY11,
|
|
1002
|
+
decls: [
|
|
1003
|
+
{ declCapture: "function", nameCapture: "function.name", kind: "function" },
|
|
1004
|
+
{ declCapture: "struct", nameCapture: "struct.name", kind: "class" },
|
|
1005
|
+
{ declCapture: "enum", nameCapture: "enum.name", kind: "enum" },
|
|
1006
|
+
{ declCapture: "trait", nameCapture: "trait.name", kind: "interface" },
|
|
1007
|
+
{ declCapture: "impl", nameCapture: "impl.name", kind: "class" }
|
|
1008
|
+
]
|
|
1009
|
+
},
|
|
1010
|
+
f,
|
|
1011
|
+
source
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// src/scanner/parsers/typescript.ts
|
|
1016
|
+
var QUERY12 = `
|
|
1017
|
+
(function_declaration name: (identifier) @function.name) @function
|
|
1018
|
+
(class_declaration name: (type_identifier) @class.name) @class
|
|
1019
|
+
(interface_declaration name: (type_identifier) @interface.name) @interface
|
|
1020
|
+
(type_alias_declaration name: (type_identifier) @type.name) @type
|
|
1021
|
+
(enum_declaration name: (identifier) @enum.name) @enum
|
|
1022
|
+
(method_definition name: (property_identifier) @method.name) @method
|
|
1023
|
+
(lexical_declaration (variable_declarator name: (identifier) @const-fn.name value: [(arrow_function) (function_expression)])) @const-fn
|
|
1024
|
+
(import_statement source: (string) @import)
|
|
1025
|
+
`;
|
|
1026
|
+
function grammarFor(ext) {
|
|
1027
|
+
if (ext === ".tsx" || ext === ".jsx") return "tsx";
|
|
1028
|
+
if (ext === ".js" || ext === ".cjs" || ext === ".mjs") return "javascript";
|
|
1029
|
+
return "typescript";
|
|
1030
|
+
}
|
|
1031
|
+
function unquote(s) {
|
|
1032
|
+
return s.replace(/^["'`]|["'`]$/g, "");
|
|
1033
|
+
}
|
|
1034
|
+
function firstLine3(text, max = 200) {
|
|
1035
|
+
const line = text.split(/\r?\n/, 1)[0] ?? "";
|
|
1036
|
+
return line.length > max ? line.slice(0, max) + "\u2026" : line;
|
|
1037
|
+
}
|
|
1038
|
+
function shapeFromCaptures(captures) {
|
|
1039
|
+
const findDecl = (k, sk) => {
|
|
1040
|
+
const decl = captures.get(k);
|
|
1041
|
+
const name = captures.get(`${k}.name`);
|
|
1042
|
+
return decl && name ? { decl, name, kind: sk } : null;
|
|
1043
|
+
};
|
|
1044
|
+
return findDecl("function", "function") ?? findDecl("class", "class") ?? findDecl("interface", "interface") ?? findDecl("type", "type") ?? findDecl("enum", "enum") ?? findDecl("method", "method") ?? findDecl("const-fn", "function");
|
|
1045
|
+
}
|
|
1046
|
+
async function parseTypeScript(f, source) {
|
|
1047
|
+
const grammar = grammarFor(f.ext);
|
|
1048
|
+
let symbols = [];
|
|
1049
|
+
let imports = [];
|
|
1050
|
+
try {
|
|
1051
|
+
const { parser, language } = await createParser(grammar);
|
|
1052
|
+
const tree = parser.parse(source);
|
|
1053
|
+
if (!tree) return { file: f, source, symbols, imports, calls: [] };
|
|
1054
|
+
const query = language.query(QUERY12);
|
|
1055
|
+
const matches = query.matches(tree.rootNode);
|
|
1056
|
+
for (const match of matches) {
|
|
1057
|
+
const byName = /* @__PURE__ */ new Map();
|
|
1058
|
+
for (const cap of match.captures) byName.set(cap.name, cap.node);
|
|
1059
|
+
const shape = shapeFromCaptures(byName);
|
|
1060
|
+
if (shape) {
|
|
1061
|
+
symbols.push({
|
|
1062
|
+
name: shape.name.text,
|
|
1063
|
+
kind: shape.kind,
|
|
1064
|
+
startLine: shape.decl.startPosition.row + 1,
|
|
1065
|
+
endLine: shape.decl.endPosition.row + 1,
|
|
1066
|
+
signature: firstLine3(shape.decl.text)
|
|
1067
|
+
});
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
const importNode = byName.get("import");
|
|
1071
|
+
if (importNode) imports.push(unquote(importNode.text));
|
|
1072
|
+
}
|
|
1073
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1074
|
+
symbols = symbols.filter((s) => {
|
|
1075
|
+
const key = `${s.name}:${s.startLine}`;
|
|
1076
|
+
if (seen.has(key)) return false;
|
|
1077
|
+
seen.add(key);
|
|
1078
|
+
return true;
|
|
1079
|
+
});
|
|
1080
|
+
imports = Array.from(new Set(imports));
|
|
1081
|
+
} catch {
|
|
1082
|
+
}
|
|
1083
|
+
return { file: f, source, symbols, imports, calls: [] };
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// src/scanner/parsers/svelte.ts
|
|
1087
|
+
var SCRIPT_RE = /<script\b[^>]*>([\s\S]*?)<\/script>/gi;
|
|
1088
|
+
function extractScripts(source) {
|
|
1089
|
+
const out = [];
|
|
1090
|
+
for (const match of source.matchAll(SCRIPT_RE)) {
|
|
1091
|
+
const full = match[0];
|
|
1092
|
+
const inner = match[1] ?? "";
|
|
1093
|
+
const openTag = full.slice(0, full.indexOf(">") + 1);
|
|
1094
|
+
const tagStart = match.index ?? 0;
|
|
1095
|
+
const contentStart = tagStart + openTag.length;
|
|
1096
|
+
const startLine = source.slice(0, contentStart).split(/\r?\n/).length;
|
|
1097
|
+
const isTsx = /\blang\s*=\s*["']?(ts|tsx|typescript)["']?/i.test(openTag);
|
|
1098
|
+
out.push({ source: inner, startLine, isTsx });
|
|
1099
|
+
}
|
|
1100
|
+
return out;
|
|
1101
|
+
}
|
|
1102
|
+
async function parseSvelte(f, source) {
|
|
1103
|
+
const blocks = extractScripts(source);
|
|
1104
|
+
const out = { file: f, source, symbols: [], imports: [], calls: [] };
|
|
1105
|
+
for (const block of blocks) {
|
|
1106
|
+
const virtual = { ...f, ext: block.isTsx ? ".ts" : ".js" };
|
|
1107
|
+
const parsed = await parseTypeScript(virtual, block.source);
|
|
1108
|
+
const offset = block.startLine - 1;
|
|
1109
|
+
for (const sym of parsed.symbols) {
|
|
1110
|
+
out.symbols.push({
|
|
1111
|
+
...sym,
|
|
1112
|
+
startLine: sym.startLine + offset,
|
|
1113
|
+
endLine: sym.endLine + offset
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
for (const imp of parsed.imports) out.imports.push(imp);
|
|
1117
|
+
}
|
|
1118
|
+
out.symbols.push({
|
|
1119
|
+
name: f.relPath.split("/").pop()?.replace(/\.svelte$/i, "") ?? f.relPath,
|
|
1120
|
+
kind: "component",
|
|
1121
|
+
startLine: 1,
|
|
1122
|
+
endLine: source.split(/\r?\n/).length,
|
|
1123
|
+
signature: f.relPath
|
|
1124
|
+
});
|
|
1125
|
+
out.imports = Array.from(new Set(out.imports));
|
|
1126
|
+
return out;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// src/scanner/parsers/vue.ts
|
|
1130
|
+
var SCRIPT_RE2 = /<script\b[^>]*>([\s\S]*?)<\/script>/gi;
|
|
1131
|
+
function extractScripts2(source) {
|
|
1132
|
+
const out = [];
|
|
1133
|
+
for (const match of source.matchAll(SCRIPT_RE2)) {
|
|
1134
|
+
const full = match[0];
|
|
1135
|
+
const inner = match[1] ?? "";
|
|
1136
|
+
const openTag = full.slice(0, full.indexOf(">") + 1);
|
|
1137
|
+
const tagStart = match.index ?? 0;
|
|
1138
|
+
const contentStart = tagStart + openTag.length;
|
|
1139
|
+
const startLine = source.slice(0, contentStart).split(/\r?\n/).length;
|
|
1140
|
+
const isTs = /\blang\s*=\s*["']?(ts|tsx|typescript)["']?/i.test(openTag);
|
|
1141
|
+
out.push({ source: inner, startLine, isTs });
|
|
1142
|
+
}
|
|
1143
|
+
return out;
|
|
1144
|
+
}
|
|
1145
|
+
async function parseVue(f, source) {
|
|
1146
|
+
const blocks = extractScripts2(source);
|
|
1147
|
+
const out = { file: f, source, symbols: [], imports: [], calls: [] };
|
|
1148
|
+
for (const block of blocks) {
|
|
1149
|
+
const virtual = { ...f, ext: block.isTs ? ".ts" : ".js" };
|
|
1150
|
+
const parsed = await parseTypeScript(virtual, block.source);
|
|
1151
|
+
const offset = block.startLine - 1;
|
|
1152
|
+
for (const sym of parsed.symbols) {
|
|
1153
|
+
out.symbols.push({
|
|
1154
|
+
...sym,
|
|
1155
|
+
startLine: sym.startLine + offset,
|
|
1156
|
+
endLine: sym.endLine + offset
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
for (const imp of parsed.imports) out.imports.push(imp);
|
|
1160
|
+
}
|
|
1161
|
+
out.symbols.push({
|
|
1162
|
+
name: f.relPath.split("/").pop()?.replace(/\.vue$/i, "") ?? f.relPath,
|
|
1163
|
+
kind: "component",
|
|
1164
|
+
startLine: 1,
|
|
1165
|
+
endLine: source.split(/\r?\n/).length,
|
|
1166
|
+
signature: f.relPath
|
|
1167
|
+
});
|
|
1168
|
+
out.imports = Array.from(new Set(out.imports));
|
|
1169
|
+
return out;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// src/scanner/parser.ts
|
|
1173
|
+
var require2 = createRequire(import.meta.url);
|
|
1174
|
+
var GRAMMAR_FILES = {
|
|
1175
|
+
typescript: "tree-sitter-wasms/out/tree-sitter-typescript.wasm",
|
|
1176
|
+
tsx: "tree-sitter-wasms/out/tree-sitter-tsx.wasm",
|
|
1177
|
+
javascript: "tree-sitter-wasms/out/tree-sitter-javascript.wasm",
|
|
1178
|
+
python: "tree-sitter-wasms/out/tree-sitter-python.wasm",
|
|
1179
|
+
go: "tree-sitter-wasms/out/tree-sitter-go.wasm",
|
|
1180
|
+
rust: "tree-sitter-wasms/out/tree-sitter-rust.wasm",
|
|
1181
|
+
java: "tree-sitter-wasms/out/tree-sitter-java.wasm",
|
|
1182
|
+
kotlin: "tree-sitter-wasms/out/tree-sitter-kotlin.wasm",
|
|
1183
|
+
php: "tree-sitter-wasms/out/tree-sitter-php.wasm",
|
|
1184
|
+
ruby: "tree-sitter-wasms/out/tree-sitter-ruby.wasm",
|
|
1185
|
+
c: "tree-sitter-wasms/out/tree-sitter-c.wasm",
|
|
1186
|
+
cpp: "tree-sitter-wasms/out/tree-sitter-cpp.wasm",
|
|
1187
|
+
dart: "tree-sitter-wasms/out/tree-sitter-dart.wasm",
|
|
1188
|
+
csharp: "tree-sitter-wasms/out/tree-sitter-c_sharp.wasm"
|
|
1189
|
+
};
|
|
1190
|
+
var parserInit = null;
|
|
1191
|
+
var languageCache = /* @__PURE__ */ new Map();
|
|
1192
|
+
async function ensureParserInit() {
|
|
1193
|
+
if (!parserInit) {
|
|
1194
|
+
parserInit = Parser.init();
|
|
1195
|
+
}
|
|
1196
|
+
return parserInit;
|
|
1197
|
+
}
|
|
1198
|
+
async function loadGrammar(name) {
|
|
1199
|
+
await ensureParserInit();
|
|
1200
|
+
const cached = languageCache.get(name);
|
|
1201
|
+
if (cached) return cached;
|
|
1202
|
+
const wasmPath = require2.resolve(GRAMMAR_FILES[name]);
|
|
1203
|
+
const lang = await Parser.Language.load(wasmPath);
|
|
1204
|
+
languageCache.set(name, lang);
|
|
1205
|
+
return lang;
|
|
1206
|
+
}
|
|
1207
|
+
async function createParser(name) {
|
|
1208
|
+
const language = await loadGrammar(name);
|
|
1209
|
+
const parser = new Parser();
|
|
1210
|
+
parser.setLanguage(language);
|
|
1211
|
+
return { parser, language };
|
|
1212
|
+
}
|
|
1213
|
+
function emptyParsed(file, source) {
|
|
1214
|
+
return { file, source, symbols: [], imports: [], calls: [] };
|
|
1215
|
+
}
|
|
1216
|
+
async function parseFile(f) {
|
|
1217
|
+
let source;
|
|
1218
|
+
try {
|
|
1219
|
+
source = await readFile3(f.absPath, "utf8");
|
|
1220
|
+
} catch {
|
|
1221
|
+
return emptyParsed(f, "");
|
|
1222
|
+
}
|
|
1223
|
+
switch (f.ext) {
|
|
1224
|
+
case ".ts":
|
|
1225
|
+
case ".tsx":
|
|
1226
|
+
case ".cts":
|
|
1227
|
+
case ".mts":
|
|
1228
|
+
case ".js":
|
|
1229
|
+
case ".jsx":
|
|
1230
|
+
case ".cjs":
|
|
1231
|
+
case ".mjs":
|
|
1232
|
+
return parseTypeScript(f, source);
|
|
1233
|
+
case ".py":
|
|
1234
|
+
case ".pyi":
|
|
1235
|
+
return parsePython(f, source);
|
|
1236
|
+
case ".svelte":
|
|
1237
|
+
return parseSvelte(f, source);
|
|
1238
|
+
case ".vue":
|
|
1239
|
+
return parseVue(f, source);
|
|
1240
|
+
case ".go":
|
|
1241
|
+
return parseGo(f, source);
|
|
1242
|
+
case ".rs":
|
|
1243
|
+
return parseRust(f, source);
|
|
1244
|
+
case ".java":
|
|
1245
|
+
return parseJava(f, source);
|
|
1246
|
+
case ".kt":
|
|
1247
|
+
case ".kts":
|
|
1248
|
+
return parseKotlin(f, source);
|
|
1249
|
+
case ".php":
|
|
1250
|
+
return parsePhp(f, source);
|
|
1251
|
+
case ".rb":
|
|
1252
|
+
return parseRuby(f, source);
|
|
1253
|
+
case ".c":
|
|
1254
|
+
case ".h":
|
|
1255
|
+
return parseC(f, source);
|
|
1256
|
+
case ".cpp":
|
|
1257
|
+
case ".cc":
|
|
1258
|
+
case ".cxx":
|
|
1259
|
+
case ".hpp":
|
|
1260
|
+
case ".hh":
|
|
1261
|
+
case ".hxx":
|
|
1262
|
+
return parseCpp(f, source);
|
|
1263
|
+
case ".dart":
|
|
1264
|
+
return parseDart(f, source);
|
|
1265
|
+
case ".cs":
|
|
1266
|
+
return parseCSharp(f, source);
|
|
1267
|
+
default:
|
|
1268
|
+
return emptyParsed(f, source);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// src/scanner/walker.ts
|
|
1273
|
+
import { readFile as readFile4, readdir, stat } from "fs/promises";
|
|
1274
|
+
import { extname, join as join4, relative as relative2, sep as sep2 } from "path";
|
|
1275
|
+
import ignore2 from "ignore";
|
|
1276
|
+
var DEFAULT_IGNORE = [
|
|
1277
|
+
".git/",
|
|
1278
|
+
".synthra/",
|
|
1279
|
+
".synthra-graph/",
|
|
1280
|
+
".claude/",
|
|
1281
|
+
"node_modules/",
|
|
1282
|
+
"dist/",
|
|
1283
|
+
"build/",
|
|
1284
|
+
"out/",
|
|
1285
|
+
"coverage/",
|
|
1286
|
+
".next/",
|
|
1287
|
+
".nuxt/",
|
|
1288
|
+
".svelte-kit/",
|
|
1289
|
+
".turbo/",
|
|
1290
|
+
".cache/",
|
|
1291
|
+
".vscode/",
|
|
1292
|
+
".idea/"
|
|
1293
|
+
];
|
|
1294
|
+
var BINARY_EXTS = /* @__PURE__ */ new Set([
|
|
1295
|
+
".png",
|
|
1296
|
+
".jpg",
|
|
1297
|
+
".jpeg",
|
|
1298
|
+
".gif",
|
|
1299
|
+
".webp",
|
|
1300
|
+
".svg",
|
|
1301
|
+
".ico",
|
|
1302
|
+
".bmp",
|
|
1303
|
+
".pdf",
|
|
1304
|
+
".zip",
|
|
1305
|
+
".tar",
|
|
1306
|
+
".gz",
|
|
1307
|
+
".7z",
|
|
1308
|
+
".rar",
|
|
1309
|
+
".mp3",
|
|
1310
|
+
".mp4",
|
|
1311
|
+
".mov",
|
|
1312
|
+
".avi",
|
|
1313
|
+
".webm",
|
|
1314
|
+
".wav",
|
|
1315
|
+
".ogg",
|
|
1316
|
+
".ttf",
|
|
1317
|
+
".otf",
|
|
1318
|
+
".woff",
|
|
1319
|
+
".woff2",
|
|
1320
|
+
".eot",
|
|
1321
|
+
".exe",
|
|
1322
|
+
".dll",
|
|
1323
|
+
".so",
|
|
1324
|
+
".dylib",
|
|
1325
|
+
".bin",
|
|
1326
|
+
".wasm",
|
|
1327
|
+
".lock",
|
|
1328
|
+
".lockb"
|
|
1329
|
+
]);
|
|
1330
|
+
async function readIgnoreFile2(path) {
|
|
1331
|
+
try {
|
|
1332
|
+
const text = await readFile4(path, "utf8");
|
|
1333
|
+
return text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("#"));
|
|
1334
|
+
} catch {
|
|
1335
|
+
return [];
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
async function buildMatcher2(root, extra) {
|
|
1339
|
+
const ig = ignore2();
|
|
1340
|
+
ig.add(DEFAULT_IGNORE);
|
|
1341
|
+
ig.add(await readIgnoreFile2(join4(root, ".gitignore")));
|
|
1342
|
+
ig.add(await readIgnoreFile2(join4(root, ".synthraignore")));
|
|
1343
|
+
if (extra.length) ig.add(extra);
|
|
1344
|
+
return ig;
|
|
1345
|
+
}
|
|
1346
|
+
function toPosix2(p) {
|
|
1347
|
+
return sep2 === "/" ? p : p.split(sep2).join("/");
|
|
1348
|
+
}
|
|
1349
|
+
async function* walk(root, options = {}) {
|
|
1350
|
+
const maxFileSize = options.maxFileSize ?? 2e6;
|
|
1351
|
+
const ig = await buildMatcher2(root, options.extraIgnore ?? []);
|
|
1352
|
+
async function* recurse(dir) {
|
|
1353
|
+
let entries;
|
|
1354
|
+
try {
|
|
1355
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
1356
|
+
} catch {
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
for (const entry of entries) {
|
|
1360
|
+
const abs = join4(dir, entry.name);
|
|
1361
|
+
const rel = relative2(root, abs);
|
|
1362
|
+
if (!rel) continue;
|
|
1363
|
+
const relPosix = toPosix2(rel);
|
|
1364
|
+
const matchPath = entry.isDirectory() ? `${relPosix}/` : relPosix;
|
|
1365
|
+
if (ig.ignores(matchPath)) continue;
|
|
1366
|
+
if (entry.isDirectory()) {
|
|
1367
|
+
yield* recurse(abs);
|
|
1368
|
+
} else if (entry.isFile()) {
|
|
1369
|
+
const ext = extname(entry.name).toLowerCase();
|
|
1370
|
+
if (BINARY_EXTS.has(ext)) continue;
|
|
1371
|
+
let size;
|
|
1372
|
+
try {
|
|
1373
|
+
const s = await stat(abs);
|
|
1374
|
+
size = s.size;
|
|
1375
|
+
} catch {
|
|
1376
|
+
continue;
|
|
1377
|
+
}
|
|
1378
|
+
if (size > maxFileSize) continue;
|
|
1379
|
+
yield { absPath: abs, relPath: relPosix, ext, size };
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
yield* recurse(root);
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// src/graph/store.ts
|
|
1387
|
+
import { mkdir as mkdir2, readFile as readFile5, writeFile } from "fs/promises";
|
|
1388
|
+
import { dirname as dirname3 } from "path";
|
|
1389
|
+
async function writeJson(path, data, pretty) {
|
|
1390
|
+
await mkdir2(dirname3(path), { recursive: true });
|
|
1391
|
+
const text = pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data);
|
|
1392
|
+
await writeFile(path, text + "\n", "utf8");
|
|
1393
|
+
}
|
|
1394
|
+
async function readJson(path) {
|
|
1395
|
+
const text = await readFile5(path, "utf8");
|
|
1396
|
+
return JSON.parse(text);
|
|
1397
|
+
}
|
|
1398
|
+
async function writeGraph(path, graph) {
|
|
1399
|
+
await writeJson(path, graph, false);
|
|
1400
|
+
}
|
|
1401
|
+
async function readGraph(path) {
|
|
1402
|
+
return readJson(path);
|
|
1403
|
+
}
|
|
1404
|
+
async function writeSymbolIndex(path, index) {
|
|
1405
|
+
await writeJson(path, index, true);
|
|
1406
|
+
}
|
|
1407
|
+
async function readSymbolIndex(path) {
|
|
1408
|
+
return readJson(path);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// src/shared/paths.ts
|
|
1412
|
+
import { join as join5 } from "path";
|
|
1413
|
+
function resolvePaths(projectRoot) {
|
|
1414
|
+
const graphDir = join5(projectRoot, ".synthra-graph");
|
|
1415
|
+
const contextDir = join5(projectRoot, ".synthra");
|
|
1416
|
+
const claudeDir = join5(projectRoot, ".claude");
|
|
1417
|
+
return {
|
|
1418
|
+
projectRoot,
|
|
1419
|
+
graphDir,
|
|
1420
|
+
contextDir,
|
|
1421
|
+
infoGraph: join5(graphDir, "info_graph.json"),
|
|
1422
|
+
symbolIndex: join5(graphDir, "symbol_index.json"),
|
|
1423
|
+
sessionState: join5(graphDir, "session.json"),
|
|
1424
|
+
activityLog: join5(graphDir, "activity.jsonl"),
|
|
1425
|
+
tokenLog: join5(graphDir, "token_log.jsonl"),
|
|
1426
|
+
gateLog: join5(graphDir, "gate_log.jsonl"),
|
|
1427
|
+
mcpPort: join5(graphDir, "mcp_port"),
|
|
1428
|
+
mcpServerLog: join5(graphDir, "mcp_server.log"),
|
|
1429
|
+
mcpServerErrLog: join5(graphDir, "mcp_server.err.log"),
|
|
1430
|
+
contextStore: join5(contextDir, "context-store.json"),
|
|
1431
|
+
contextMd: join5(contextDir, "CONTEXT.md"),
|
|
1432
|
+
branchesDir: join5(contextDir, "branches"),
|
|
1433
|
+
claudeDir,
|
|
1434
|
+
claudeSettings: join5(claudeDir, "settings.local.json"),
|
|
1435
|
+
claudeHooksDir: join5(claudeDir, "hooks"),
|
|
1436
|
+
claudeMd: join5(projectRoot, "CLAUDE.md"),
|
|
1437
|
+
gitignore: join5(projectRoot, ".gitignore")
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// src/cli/bootstrap.ts
|
|
1442
|
+
import { mkdir as mkdir3, readFile as readFile7, stat as stat2, writeFile as writeFile3 } from "fs/promises";
|
|
1443
|
+
|
|
1444
|
+
// src/hooks/claude-md.ts
|
|
1445
|
+
import { readFile as readFile6, writeFile as writeFile2 } from "fs/promises";
|
|
1446
|
+
var POLICY_VERSION = 1;
|
|
1447
|
+
var POLICY_BEGIN = `<!-- synthra-policy v${POLICY_VERSION} BEGIN -->`;
|
|
1448
|
+
var POLICY_END = `<!-- synthra-policy v${POLICY_VERSION} END -->`;
|
|
1449
|
+
var ANY_BLOCK_RE = /<!--\s*synthra-policy\s+v\d+\s+BEGIN\s*-->[\s\S]*?<!--\s*synthra-policy\s+v\d+\s+END\s*-->\s*/g;
|
|
1450
|
+
function policyBlock() {
|
|
1451
|
+
return [
|
|
1452
|
+
POLICY_BEGIN,
|
|
1453
|
+
"## Synthra context policy",
|
|
1454
|
+
"",
|
|
1455
|
+
"Synthra has pre-loaded a structured context pack into this session and",
|
|
1456
|
+
"exposes the project's code graph through three MCP tools. **Prefer these",
|
|
1457
|
+
"tools over Grep / Glob** \u2014 they are faster, cheaper, and already filtered",
|
|
1458
|
+
"to relevant files.",
|
|
1459
|
+
"",
|
|
1460
|
+
"### Tools",
|
|
1461
|
+
"",
|
|
1462
|
+
"- **`graph_continue(query)`** \u2014 your default first move when you need",
|
|
1463
|
+
" project context. Returns signatures + top function bodies + linked test",
|
|
1464
|
+
' files, with a `confidence` label. If `confidence === "high"`, **stop**:',
|
|
1465
|
+
" do not call Grep/Glob for the same query.",
|
|
1466
|
+
'- **`graph_read(target)`** \u2014 fetch source for a specific `"file/path.ts"`',
|
|
1467
|
+
' or `"file/path.ts::SymbolName"`. Use this once you know what you want.',
|
|
1468
|
+
"- **`graph_register_edit(files)`** \u2014 after you edit files, call this so",
|
|
1469
|
+
" Synthra ranks them higher and avoids surfacing stale snapshots.",
|
|
1470
|
+
"",
|
|
1471
|
+
"### Rules",
|
|
1472
|
+
"",
|
|
1473
|
+
"1. Call `graph_continue` **before** Grep / Glob for any question about",
|
|
1474
|
+
" project code. Grep / Glob calls for the same query may be blocked at",
|
|
1475
|
+
" the hook layer when the graph already has a confident answer.",
|
|
1476
|
+
'2. When `graph_continue` returns `confidence: "high"`, treat the pack as',
|
|
1477
|
+
" authoritative \u2014 don't second-guess it with a Grep.",
|
|
1478
|
+
"3. Use `graph_read` instead of `Read` when you only need a specific symbol",
|
|
1479
|
+
" from a file (you get less noise + line numbers).",
|
|
1480
|
+
"4. After editing files, call `graph_register_edit(files)` so subsequent",
|
|
1481
|
+
" turns weight your changes correctly.",
|
|
1482
|
+
"",
|
|
1483
|
+
"_This block is managed by Synthra. Edits inside the BEGIN/END markers",
|
|
1484
|
+
"are overwritten on every `syn .` run._",
|
|
1485
|
+
"",
|
|
1486
|
+
POLICY_END
|
|
1487
|
+
].join("\n");
|
|
1488
|
+
}
|
|
1489
|
+
async function patchClaudeMd(path) {
|
|
1490
|
+
let existing;
|
|
1491
|
+
try {
|
|
1492
|
+
existing = await readFile6(path, "utf8");
|
|
1493
|
+
} catch {
|
|
1494
|
+
existing = null;
|
|
1495
|
+
}
|
|
1496
|
+
const block = policyBlock();
|
|
1497
|
+
if (existing === null) {
|
|
1498
|
+
await writeFile2(path, block + "\n", "utf8");
|
|
1499
|
+
return { created: true, updated: false, skipped: false };
|
|
1500
|
+
}
|
|
1501
|
+
const stripped = existing.replace(ANY_BLOCK_RE, "");
|
|
1502
|
+
const hadBlock = stripped !== existing;
|
|
1503
|
+
const desired = stripped.endsWith("\n") ? stripped + "\n" + block + "\n" : (stripped.length ? stripped + "\n\n" : "") + block + "\n";
|
|
1504
|
+
if (hadBlock && desired === existing) {
|
|
1505
|
+
return { created: false, updated: false, skipped: true };
|
|
1506
|
+
}
|
|
1507
|
+
await writeFile2(path, desired, "utf8");
|
|
1508
|
+
return { created: false, updated: true, skipped: false };
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
// src/cli/bootstrap.ts
|
|
1512
|
+
var GITIGNORE_MARKER = "# added by synthra";
|
|
1513
|
+
var GITIGNORE_ENTRY = ".synthra-graph/";
|
|
1514
|
+
async function exists(path) {
|
|
1515
|
+
try {
|
|
1516
|
+
await stat2(path);
|
|
1517
|
+
return true;
|
|
1518
|
+
} catch {
|
|
1519
|
+
return false;
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
async function ensureDir(path) {
|
|
1523
|
+
const had = await exists(path);
|
|
1524
|
+
await mkdir3(path, { recursive: true });
|
|
1525
|
+
return !had;
|
|
1526
|
+
}
|
|
1527
|
+
async function patchGitignore(path) {
|
|
1528
|
+
let existing = "";
|
|
1529
|
+
try {
|
|
1530
|
+
existing = await readFile7(path, "utf8");
|
|
1531
|
+
} catch {
|
|
1532
|
+
}
|
|
1533
|
+
const lines = existing.split(/\r?\n/);
|
|
1534
|
+
if (lines.some((l) => l.trim() === GITIGNORE_ENTRY)) return false;
|
|
1535
|
+
const appendix = (existing.length === 0 || existing.endsWith("\n") ? "" : "\n") + (existing.length ? "\n" : "") + `${GITIGNORE_MARKER}
|
|
1536
|
+
${GITIGNORE_ENTRY}
|
|
1537
|
+
`;
|
|
1538
|
+
await writeFile3(path, existing + appendix, "utf8");
|
|
1539
|
+
return true;
|
|
1540
|
+
}
|
|
1541
|
+
async function bootstrap(paths) {
|
|
1542
|
+
const graphCreated = await ensureDir(paths.graphDir);
|
|
1543
|
+
const contextCreated = await ensureDir(paths.contextDir);
|
|
1544
|
+
const gitignoreUpdated = await patchGitignore(paths.gitignore);
|
|
1545
|
+
const claudeMdExistedBefore = await exists(paths.claudeMd);
|
|
1546
|
+
const patch = await patchClaudeMd(paths.claudeMd);
|
|
1547
|
+
return {
|
|
1548
|
+
graphCreated,
|
|
1549
|
+
contextCreated,
|
|
1550
|
+
gitignoreUpdated,
|
|
1551
|
+
claudeMdUpdated: patch.updated,
|
|
1552
|
+
claudeMdCreated: patch.created && !claudeMdExistedBefore
|
|
1553
|
+
};
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// src/cli/scan-command.ts
|
|
1557
|
+
var PARSABLE_EXTS = /* @__PURE__ */ new Set([
|
|
1558
|
+
".ts",
|
|
1559
|
+
".tsx",
|
|
1560
|
+
".cts",
|
|
1561
|
+
".mts",
|
|
1562
|
+
".js",
|
|
1563
|
+
".jsx",
|
|
1564
|
+
".cjs",
|
|
1565
|
+
".mjs",
|
|
1566
|
+
".py",
|
|
1567
|
+
".pyi",
|
|
1568
|
+
".svelte",
|
|
1569
|
+
".vue",
|
|
1570
|
+
".go",
|
|
1571
|
+
".rs",
|
|
1572
|
+
".java",
|
|
1573
|
+
".kt",
|
|
1574
|
+
".kts",
|
|
1575
|
+
".php",
|
|
1576
|
+
".rb",
|
|
1577
|
+
".c",
|
|
1578
|
+
".h",
|
|
1579
|
+
".cpp",
|
|
1580
|
+
".cc",
|
|
1581
|
+
".cxx",
|
|
1582
|
+
".hpp",
|
|
1583
|
+
".hh",
|
|
1584
|
+
".hxx",
|
|
1585
|
+
".dart",
|
|
1586
|
+
".cs"
|
|
1587
|
+
]);
|
|
1588
|
+
async function scanProject(projectRootRaw, opts = {}) {
|
|
1589
|
+
const projectRoot = resolve(projectRootRaw);
|
|
1590
|
+
const paths = resolvePaths(projectRoot);
|
|
1591
|
+
const start = Date.now();
|
|
1592
|
+
const verbose = !opts.silent;
|
|
1593
|
+
if (verbose) log.info(`scanning ${projectRoot}`);
|
|
1594
|
+
const boot = await bootstrap(paths);
|
|
1595
|
+
if (verbose) {
|
|
1596
|
+
if (boot.graphCreated) log.info(" created .synthra-graph/");
|
|
1597
|
+
if (boot.contextCreated) log.info(" created .synthra/");
|
|
1598
|
+
if (boot.gitignoreUpdated) log.info(" updated .gitignore");
|
|
1599
|
+
if (boot.claudeMdCreated) log.info(" created CLAUDE.md");
|
|
1600
|
+
else if (boot.claudeMdUpdated) log.info(" updated CLAUDE.md");
|
|
1601
|
+
}
|
|
1602
|
+
const walked = [];
|
|
1603
|
+
for await (const file of walk(projectRoot)) walked.push(file);
|
|
1604
|
+
if (verbose) log.info(` walked ${walked.length} files`);
|
|
1605
|
+
const parsable = walked.filter((f) => PARSABLE_EXTS.has(f.ext));
|
|
1606
|
+
const parsed = [];
|
|
1607
|
+
let parseErrors = 0;
|
|
1608
|
+
for (const file of parsable) {
|
|
1609
|
+
try {
|
|
1610
|
+
parsed.push(await parseFile(file));
|
|
1611
|
+
} catch (err2) {
|
|
1612
|
+
parseErrors += 1;
|
|
1613
|
+
if (verbose) log.debug(` parse failed: ${file.relPath} \u2014 ${err2.message}`);
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
if (verbose) {
|
|
1617
|
+
log.info(
|
|
1618
|
+
` parsed ${parsed.length} files (${walked.length - parsable.length} skipped` + (parseErrors ? `, ${parseErrors} errored` : "") + ")"
|
|
1619
|
+
);
|
|
1620
|
+
}
|
|
1621
|
+
const graph = await buildGraph(projectRoot, parsed);
|
|
1622
|
+
const symbolIndex = buildSymbolIndex(graph);
|
|
1623
|
+
await writeGraph(paths.infoGraph, graph);
|
|
1624
|
+
await writeSymbolIndex(paths.symbolIndex, symbolIndex);
|
|
1625
|
+
if (verbose) {
|
|
1626
|
+
log.info(
|
|
1627
|
+
` wrote ${paths.infoGraph} \u2014 ${graph.symbol_count} symbols, ${graph.edge_count} edges`
|
|
1628
|
+
);
|
|
1629
|
+
log.info(` wrote ${paths.symbolIndex} \u2014 ${Object.keys(symbolIndex).length} names`);
|
|
1630
|
+
}
|
|
1631
|
+
const durationMs = Date.now() - start;
|
|
1632
|
+
if (verbose) log.info(`done in ${(durationMs / 1e3).toFixed(2)}s`);
|
|
1633
|
+
return {
|
|
1634
|
+
walked: walked.length,
|
|
1635
|
+
parsed: parsed.length,
|
|
1636
|
+
symbolCount: graph.symbol_count,
|
|
1637
|
+
edgeCount: graph.edge_count,
|
|
1638
|
+
durationMs
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// src/graph/rank.ts
|
|
1643
|
+
var STOPWORDS2 = /* @__PURE__ */ new Set([
|
|
1644
|
+
"a",
|
|
1645
|
+
"an",
|
|
1646
|
+
"and",
|
|
1647
|
+
"are",
|
|
1648
|
+
"as",
|
|
1649
|
+
"at",
|
|
1650
|
+
"be",
|
|
1651
|
+
"by",
|
|
1652
|
+
"for",
|
|
1653
|
+
"from",
|
|
1654
|
+
"has",
|
|
1655
|
+
"have",
|
|
1656
|
+
"in",
|
|
1657
|
+
"is",
|
|
1658
|
+
"it",
|
|
1659
|
+
"of",
|
|
1660
|
+
"on",
|
|
1661
|
+
"or",
|
|
1662
|
+
"that",
|
|
1663
|
+
"the",
|
|
1664
|
+
"this",
|
|
1665
|
+
"to",
|
|
1666
|
+
"was",
|
|
1667
|
+
"we",
|
|
1668
|
+
"with",
|
|
1669
|
+
"what",
|
|
1670
|
+
"where",
|
|
1671
|
+
"when",
|
|
1672
|
+
"why",
|
|
1673
|
+
"how",
|
|
1674
|
+
"do",
|
|
1675
|
+
"does",
|
|
1676
|
+
"i",
|
|
1677
|
+
"me",
|
|
1678
|
+
"my",
|
|
1679
|
+
"you",
|
|
1680
|
+
"your",
|
|
1681
|
+
"code",
|
|
1682
|
+
"file"
|
|
1683
|
+
]);
|
|
1684
|
+
function tokenizeQuery(query) {
|
|
1685
|
+
const tokens = query.toLowerCase().split(/[^a-z0-9_]+/g).filter((t) => t.length > 1 && !STOPWORDS2.has(t));
|
|
1686
|
+
const expanded = /* @__PURE__ */ new Set();
|
|
1687
|
+
for (const t of tokens) {
|
|
1688
|
+
expanded.add(t);
|
|
1689
|
+
const parts = t.match(/[a-z]+|[0-9]+/g) ?? [];
|
|
1690
|
+
for (const p of parts) if (p.length > 1) expanded.add(p);
|
|
1691
|
+
}
|
|
1692
|
+
return Array.from(expanded);
|
|
1693
|
+
}
|
|
1694
|
+
function indexSymbolsByFile(graph) {
|
|
1695
|
+
const out = /* @__PURE__ */ new Map();
|
|
1696
|
+
if (!graph) return out;
|
|
1697
|
+
for (const n of graph.nodes) {
|
|
1698
|
+
if (n.kind !== "symbol") continue;
|
|
1699
|
+
const list = out.get(n.file) ?? [];
|
|
1700
|
+
list.push(n);
|
|
1701
|
+
out.set(n.file, list);
|
|
1702
|
+
}
|
|
1703
|
+
return out;
|
|
1704
|
+
}
|
|
1705
|
+
function indexImportEdges(graph) {
|
|
1706
|
+
const out = /* @__PURE__ */ new Map();
|
|
1707
|
+
if (!graph) return out;
|
|
1708
|
+
const idToPath = /* @__PURE__ */ new Map();
|
|
1709
|
+
for (const n of graph.nodes) if (n.kind === "file") idToPath.set(n.id, n.path);
|
|
1710
|
+
for (const e of graph.edges) {
|
|
1711
|
+
if (e.kind !== "imports") continue;
|
|
1712
|
+
const from = idToPath.get(e.from);
|
|
1713
|
+
const to = idToPath.get(e.to);
|
|
1714
|
+
if (!from || !to) continue;
|
|
1715
|
+
const s = out.get(from) ?? /* @__PURE__ */ new Set();
|
|
1716
|
+
s.add(to);
|
|
1717
|
+
out.set(from, s);
|
|
1718
|
+
}
|
|
1719
|
+
return out;
|
|
1720
|
+
}
|
|
1721
|
+
function scoreFiles(inputs) {
|
|
1722
|
+
const qTokens = new Set(tokenizeQuery(inputs.query));
|
|
1723
|
+
const symbolsByFile = indexSymbolsByFile(inputs.graph);
|
|
1724
|
+
const importsFrom = indexImportEdges(inputs.graph);
|
|
1725
|
+
const seeds = new Set(inputs.sessionKnownPaths ?? []);
|
|
1726
|
+
for (const p of inputs.recentlyEditedPaths ?? []) seeds.add(p);
|
|
1727
|
+
const scored = [];
|
|
1728
|
+
for (const file of inputs.candidates) {
|
|
1729
|
+
const reasons = [];
|
|
1730
|
+
let score2 = 0;
|
|
1731
|
+
let kwHits = 0;
|
|
1732
|
+
for (const kw of file.keywords) if (qTokens.has(kw)) kwHits += 1;
|
|
1733
|
+
if (kwHits) {
|
|
1734
|
+
score2 += kwHits * 2;
|
|
1735
|
+
reasons.push(`kw=${kwHits}`);
|
|
1736
|
+
}
|
|
1737
|
+
const symbols = symbolsByFile.get(file.path) ?? [];
|
|
1738
|
+
let symHits = 0;
|
|
1739
|
+
for (const sym of symbols) {
|
|
1740
|
+
const name = sym.name.toLowerCase();
|
|
1741
|
+
if (qTokens.has(name)) {
|
|
1742
|
+
symHits += 3;
|
|
1743
|
+
} else {
|
|
1744
|
+
for (const t of qTokens) {
|
|
1745
|
+
if (name.includes(t) || t.includes(name)) {
|
|
1746
|
+
symHits += 1;
|
|
1747
|
+
break;
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
if (symHits) {
|
|
1753
|
+
score2 += symHits;
|
|
1754
|
+
reasons.push(`sym=${symHits}`);
|
|
1755
|
+
}
|
|
1756
|
+
const pathLower = file.path.toLowerCase();
|
|
1757
|
+
let pathHits = 0;
|
|
1758
|
+
for (const t of qTokens) if (pathLower.includes(t)) pathHits += 1;
|
|
1759
|
+
if (pathHits) {
|
|
1760
|
+
score2 += pathHits;
|
|
1761
|
+
reasons.push(`path=${pathHits}`);
|
|
1762
|
+
}
|
|
1763
|
+
if (seeds.has(file.path)) {
|
|
1764
|
+
score2 += 5;
|
|
1765
|
+
reasons.push("seed");
|
|
1766
|
+
}
|
|
1767
|
+
scored.push({ file, score: score2, reasons });
|
|
1768
|
+
}
|
|
1769
|
+
const positivePaths = new Set(scored.filter((s) => s.score > 0).map((s) => s.file.path));
|
|
1770
|
+
if (positivePaths.size > 0) {
|
|
1771
|
+
for (const s of scored) {
|
|
1772
|
+
if (s.score > 0) continue;
|
|
1773
|
+
let importBoost = 0;
|
|
1774
|
+
for (const [from, tos] of importsFrom) {
|
|
1775
|
+
if (!positivePaths.has(from)) continue;
|
|
1776
|
+
if (tos.has(s.file.path)) {
|
|
1777
|
+
importBoost += 1;
|
|
1778
|
+
break;
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
if (importBoost) {
|
|
1782
|
+
s.score += importBoost * 0.5;
|
|
1783
|
+
s.reasons.push("imp-adj");
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
scored.sort((a, b) => b.score - a.score);
|
|
1788
|
+
return scored;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
// src/graph/retrieve.ts
|
|
1792
|
+
async function retrieve(graph, query, options = {}) {
|
|
1793
|
+
const topK = options.topK ?? 12;
|
|
1794
|
+
const qTokens = tokenizeQuery(query);
|
|
1795
|
+
const allFiles = graph.nodes.filter(
|
|
1796
|
+
(n) => n.kind === "file"
|
|
1797
|
+
);
|
|
1798
|
+
if (allFiles.length === 0 || qTokens.length === 0) {
|
|
1799
|
+
return {
|
|
1800
|
+
files: [],
|
|
1801
|
+
confidence: "low",
|
|
1802
|
+
reason: qTokens.length === 0 ? "empty query" : "empty graph"
|
|
1803
|
+
};
|
|
1804
|
+
}
|
|
1805
|
+
const rankInputs = {
|
|
1806
|
+
candidates: allFiles,
|
|
1807
|
+
query,
|
|
1808
|
+
graph,
|
|
1809
|
+
recentlyEditedPaths: options.recentlyEditedPaths,
|
|
1810
|
+
sessionKnownPaths: options.sessionKnownPaths
|
|
1811
|
+
};
|
|
1812
|
+
const scored = scoreFiles(rankInputs);
|
|
1813
|
+
const positive = scored.filter((s) => s.score > 0);
|
|
1814
|
+
if (positive.length === 0) {
|
|
1815
|
+
return {
|
|
1816
|
+
files: [],
|
|
1817
|
+
confidence: "low",
|
|
1818
|
+
reason: `no matches for ${JSON.stringify(qTokens)}`
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1821
|
+
const top = positive.slice(0, topK).map((s) => s.file);
|
|
1822
|
+
const topScore = positive[0]?.score ?? 0;
|
|
1823
|
+
const secondScore = positive[1]?.score ?? 0;
|
|
1824
|
+
let confidence;
|
|
1825
|
+
if (positive.length === 1) confidence = "high";
|
|
1826
|
+
else if (topScore >= 6 && topScore >= secondScore * 2) confidence = "high";
|
|
1827
|
+
else if (topScore >= 3) confidence = "medium";
|
|
1828
|
+
else confidence = "low";
|
|
1829
|
+
const reasons = positive.slice(0, Math.min(3, top.length)).map((s) => `${s.file.path} (${s.reasons.join(",")})`).join("; ");
|
|
1830
|
+
return {
|
|
1831
|
+
files: top,
|
|
1832
|
+
confidence,
|
|
1833
|
+
reason: `top: ${reasons}`
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
// src/memory/branches.ts
|
|
1838
|
+
import { execFile as execFile2 } from "child_process";
|
|
1839
|
+
import { readFile as readFile8 } from "fs/promises";
|
|
1840
|
+
import { join as join6 } from "path";
|
|
1841
|
+
import { promisify as promisify2 } from "util";
|
|
1842
|
+
var execFileAsync2 = promisify2(execFile2);
|
|
1843
|
+
async function currentBranch(projectRoot) {
|
|
1844
|
+
try {
|
|
1845
|
+
const headPath = join6(projectRoot, ".git", "HEAD");
|
|
1846
|
+
const head = await readFile8(headPath, "utf8");
|
|
1847
|
+
const trimmed = head.trim();
|
|
1848
|
+
const match = trimmed.match(/^ref:\s+refs\/heads\/(.+)$/);
|
|
1849
|
+
if (match?.[1]) return match[1];
|
|
1850
|
+
} catch {
|
|
1851
|
+
}
|
|
1852
|
+
try {
|
|
1853
|
+
const { stdout } = await execFileAsync2("git", ["branch", "--show-current"], {
|
|
1854
|
+
cwd: projectRoot
|
|
1855
|
+
});
|
|
1856
|
+
const name = stdout.trim();
|
|
1857
|
+
if (name) return name;
|
|
1858
|
+
} catch {
|
|
1859
|
+
}
|
|
1860
|
+
return "main";
|
|
1861
|
+
}
|
|
1862
|
+
async function defaultBranch(projectRoot) {
|
|
1863
|
+
try {
|
|
1864
|
+
const { stdout } = await execFileAsync2(
|
|
1865
|
+
"git",
|
|
1866
|
+
["symbolic-ref", "refs/remotes/origin/HEAD", "--short"],
|
|
1867
|
+
{ cwd: projectRoot }
|
|
1868
|
+
);
|
|
1869
|
+
const trimmed = stdout.trim();
|
|
1870
|
+
const match = trimmed.match(/^origin\/(.+)$/);
|
|
1871
|
+
if (match?.[1]) return match[1];
|
|
1872
|
+
} catch {
|
|
1873
|
+
}
|
|
1874
|
+
return "main";
|
|
1875
|
+
}
|
|
1876
|
+
function sanitizeBranchName(name) {
|
|
1877
|
+
return name.replaceAll("/", "-").replaceAll("\\", "-");
|
|
1878
|
+
}
|
|
1879
|
+
function resolveBranchPaths(contextDir, branch, isDefault) {
|
|
1880
|
+
if (isDefault) {
|
|
1881
|
+
return {
|
|
1882
|
+
contextStore: join6(contextDir, "context-store.json"),
|
|
1883
|
+
contextMd: join6(contextDir, "CONTEXT.md"),
|
|
1884
|
+
branchDir: null
|
|
1885
|
+
};
|
|
1886
|
+
}
|
|
1887
|
+
const branchDir = join6(contextDir, "branches", sanitizeBranchName(branch));
|
|
1888
|
+
return {
|
|
1889
|
+
contextStore: join6(branchDir, "context-store.json"),
|
|
1890
|
+
contextMd: join6(branchDir, "CONTEXT.md"),
|
|
1891
|
+
branchDir
|
|
1892
|
+
};
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
// src/memory/context-md.ts
|
|
1896
|
+
import { mkdir as mkdir4, readFile as readFile9, writeFile as writeFile4 } from "fs/promises";
|
|
1897
|
+
import { dirname as dirname4 } from "path";
|
|
1898
|
+
var MAX_BULLETS = 3;
|
|
1899
|
+
function deriveContextMd(entries, branch) {
|
|
1900
|
+
const tasks = entries.filter((e) => e.type === "task").reverse();
|
|
1901
|
+
const currentTask = tasks[0]?.content ?? "";
|
|
1902
|
+
const keyDecisions = entries.filter((e) => e.type === "decision").slice(-MAX_BULLETS).map((e) => e.content);
|
|
1903
|
+
const nextSteps = entries.filter((e) => e.type === "next").slice(-MAX_BULLETS).map((e) => e.content);
|
|
1904
|
+
return {
|
|
1905
|
+
branch,
|
|
1906
|
+
currentTask,
|
|
1907
|
+
keyDecisions,
|
|
1908
|
+
nextSteps,
|
|
1909
|
+
date: (/* @__PURE__ */ new Date()).toISOString()
|
|
1910
|
+
};
|
|
1911
|
+
}
|
|
1912
|
+
function formatContextMd(ctx) {
|
|
1913
|
+
const lines = [];
|
|
1914
|
+
lines.push(`# Context \u2014 ${ctx.branch}`);
|
|
1915
|
+
lines.push("");
|
|
1916
|
+
lines.push(`_Updated: ${ctx.date}_`);
|
|
1917
|
+
lines.push("");
|
|
1918
|
+
if (ctx.currentTask) {
|
|
1919
|
+
lines.push(`## Current task`);
|
|
1920
|
+
lines.push(ctx.currentTask);
|
|
1921
|
+
lines.push("");
|
|
1922
|
+
}
|
|
1923
|
+
if (ctx.keyDecisions.length) {
|
|
1924
|
+
lines.push(`## Key decisions`);
|
|
1925
|
+
for (const d of ctx.keyDecisions) lines.push(`- ${d}`);
|
|
1926
|
+
lines.push("");
|
|
1927
|
+
}
|
|
1928
|
+
if (ctx.nextSteps.length) {
|
|
1929
|
+
lines.push(`## Next steps`);
|
|
1930
|
+
for (const n of ctx.nextSteps) lines.push(`- ${n}`);
|
|
1931
|
+
lines.push("");
|
|
1932
|
+
}
|
|
1933
|
+
if (!ctx.currentTask && !ctx.keyDecisions.length && !ctx.nextSteps.length) {
|
|
1934
|
+
lines.push("_(no context entries yet \u2014 use `context_remember` to add one)_");
|
|
1935
|
+
lines.push("");
|
|
1936
|
+
}
|
|
1937
|
+
return lines.join("\n");
|
|
1938
|
+
}
|
|
1939
|
+
async function writeContextMd(path, ctx) {
|
|
1940
|
+
await mkdir4(dirname4(path), { recursive: true });
|
|
1941
|
+
await writeFile4(path, formatContextMd(ctx), "utf8");
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
// src/memory/context-store.ts
|
|
1945
|
+
import { mkdir as mkdir5, readFile as readFile10, writeFile as writeFile5 } from "fs/promises";
|
|
1946
|
+
import { dirname as dirname5 } from "path";
|
|
1947
|
+
var SCHEMA_VERSION = 1;
|
|
1948
|
+
async function readEntries(path) {
|
|
1949
|
+
try {
|
|
1950
|
+
const raw = await readFile10(path, "utf8");
|
|
1951
|
+
const parsed = JSON.parse(raw);
|
|
1952
|
+
return Array.isArray(parsed.entries) ? parsed.entries : [];
|
|
1953
|
+
} catch {
|
|
1954
|
+
return [];
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
async function writeEntries(path, entries) {
|
|
1958
|
+
await mkdir5(dirname5(path), { recursive: true });
|
|
1959
|
+
const store = { schema_version: SCHEMA_VERSION, entries };
|
|
1960
|
+
await writeFile5(path, JSON.stringify(store, null, 2) + "\n", "utf8");
|
|
1961
|
+
}
|
|
1962
|
+
async function appendEntry(path, entry) {
|
|
1963
|
+
const entries = await readEntries(path);
|
|
1964
|
+
entries.push(entry);
|
|
1965
|
+
await writeEntries(path, entries);
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
// src/memory/index.ts
|
|
1969
|
+
async function resolveActiveBranch(paths, override) {
|
|
1970
|
+
const branch = override ?? await currentBranch(paths.projectRoot);
|
|
1971
|
+
const def = await defaultBranch(paths.projectRoot);
|
|
1972
|
+
const isDefault = branch === def;
|
|
1973
|
+
return {
|
|
1974
|
+
branch,
|
|
1975
|
+
isDefault,
|
|
1976
|
+
paths: resolveBranchPaths(paths.contextDir, branch, isDefault)
|
|
1977
|
+
};
|
|
1978
|
+
}
|
|
1979
|
+
async function rememberEntry(paths, input) {
|
|
1980
|
+
const active = await resolveActiveBranch(paths);
|
|
1981
|
+
const entry = {
|
|
1982
|
+
type: input.kind,
|
|
1983
|
+
content: input.text,
|
|
1984
|
+
tags: input.tags ?? [],
|
|
1985
|
+
files: input.files ?? [],
|
|
1986
|
+
date: (/* @__PURE__ */ new Date()).toISOString()
|
|
1987
|
+
};
|
|
1988
|
+
await appendEntry(active.paths.contextStore, entry);
|
|
1989
|
+
const entries = await readEntries(active.paths.contextStore);
|
|
1990
|
+
const md = deriveContextMd(entries, active.branch);
|
|
1991
|
+
await writeContextMd(active.paths.contextMd, md);
|
|
1992
|
+
return {
|
|
1993
|
+
entry,
|
|
1994
|
+
branch: active.branch,
|
|
1995
|
+
storePath: active.paths.contextStore,
|
|
1996
|
+
contextMdPath: active.paths.contextMd
|
|
1997
|
+
};
|
|
1998
|
+
}
|
|
1999
|
+
async function recallEntries(paths, input = {}) {
|
|
2000
|
+
const active = await resolveActiveBranch(paths, input.branch);
|
|
2001
|
+
let entries = await readEntries(active.paths.contextStore);
|
|
2002
|
+
if (input.kind) entries = entries.filter((e) => e.type === input.kind);
|
|
2003
|
+
if (input.limit && input.limit > 0) entries = entries.slice(-input.limit);
|
|
2004
|
+
return {
|
|
2005
|
+
branch: active.branch,
|
|
2006
|
+
entries,
|
|
2007
|
+
storePath: active.paths.contextStore
|
|
2008
|
+
};
|
|
2009
|
+
}
|
|
2010
|
+
async function refreshContextMd(paths, branchOverride) {
|
|
2011
|
+
const active = await resolveActiveBranch(paths, branchOverride);
|
|
2012
|
+
const entries = await readEntries(active.paths.contextStore);
|
|
2013
|
+
const md = deriveContextMd(entries, active.branch);
|
|
2014
|
+
await writeContextMd(active.paths.contextMd, md);
|
|
2015
|
+
return {
|
|
2016
|
+
branch: active.branch,
|
|
2017
|
+
path: active.paths.contextMd,
|
|
2018
|
+
entriesSeen: entries.length
|
|
2019
|
+
};
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
// src/packer/format.ts
|
|
2023
|
+
function formatPack(inputs) {
|
|
2024
|
+
const parts = [];
|
|
2025
|
+
parts.push(`# Synthra context \u2014 query: ${JSON.stringify(inputs.query)}
|
|
2026
|
+
`);
|
|
2027
|
+
if (inputs.files.length === 0) {
|
|
2028
|
+
parts.push("> No matching files found in the graph.\n");
|
|
2029
|
+
}
|
|
2030
|
+
for (const f of inputs.files) {
|
|
2031
|
+
const heading = f.reason ? `## ${f.path} _(${f.reason})_` : `## ${f.path}`;
|
|
2032
|
+
parts.push(heading);
|
|
2033
|
+
if (f.signatures.length === 0) {
|
|
2034
|
+
parts.push("_(no symbols extracted)_");
|
|
2035
|
+
} else {
|
|
2036
|
+
parts.push("**Signatures:**");
|
|
2037
|
+
for (const s of f.signatures) parts.push(`- ${s}`);
|
|
2038
|
+
}
|
|
2039
|
+
if (f.inlineBodies.trim().length > 0) {
|
|
2040
|
+
parts.push("");
|
|
2041
|
+
parts.push("**Bodies:**");
|
|
2042
|
+
parts.push("```");
|
|
2043
|
+
parts.push(f.inlineBodies.trimEnd());
|
|
2044
|
+
parts.push("```");
|
|
2045
|
+
}
|
|
2046
|
+
if (f.associatedTests?.length) {
|
|
2047
|
+
parts.push("");
|
|
2048
|
+
parts.push(`**Tests:** ${f.associatedTests.join(", ")}`);
|
|
2049
|
+
}
|
|
2050
|
+
parts.push("");
|
|
2051
|
+
}
|
|
2052
|
+
if (inputs.recentActivity?.trim()) {
|
|
2053
|
+
parts.push("---");
|
|
2054
|
+
parts.push("## Recent human activity");
|
|
2055
|
+
parts.push(inputs.recentActivity.trim());
|
|
2056
|
+
parts.push("");
|
|
2057
|
+
}
|
|
2058
|
+
if (inputs.truncated) {
|
|
2059
|
+
parts.push("> _(pack truncated to fit budget)_");
|
|
2060
|
+
}
|
|
2061
|
+
return parts.join("\n");
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
// src/packer/inline.ts
|
|
2065
|
+
var INLINABLE_KINDS = /* @__PURE__ */ new Set(["function", "method", "class"]);
|
|
2066
|
+
var MAX_BODY_CHARS = 1500;
|
|
2067
|
+
function sliceLines(content, startLine, endLine) {
|
|
2068
|
+
const lines = content.split(/\r?\n/);
|
|
2069
|
+
return lines.slice(Math.max(0, startLine - 1), endLine).join("\n");
|
|
2070
|
+
}
|
|
2071
|
+
function scoreSymbol(name, qTokens) {
|
|
2072
|
+
const lower = name.toLowerCase();
|
|
2073
|
+
if (qTokens.has(lower)) return 3;
|
|
2074
|
+
for (const t of qTokens) {
|
|
2075
|
+
if (lower.includes(t) || t.includes(lower)) return 1;
|
|
2076
|
+
}
|
|
2077
|
+
return 0;
|
|
2078
|
+
}
|
|
2079
|
+
function truncate(body) {
|
|
2080
|
+
if (body.length <= MAX_BODY_CHARS) return body;
|
|
2081
|
+
return body.slice(0, MAX_BODY_CHARS).trimEnd() + "\n // \u2026 truncated";
|
|
2082
|
+
}
|
|
2083
|
+
function selectInlineBodies(file, symbols, query, budgetChars) {
|
|
2084
|
+
if (budgetChars <= 0) {
|
|
2085
|
+
return { text: "", charsUsed: 0, functionsInlined: [] };
|
|
2086
|
+
}
|
|
2087
|
+
const qTokens = new Set(tokenizeQuery(query));
|
|
2088
|
+
const mine = symbols.filter((s) => s.file === file.path && INLINABLE_KINDS.has(s.symbol_kind));
|
|
2089
|
+
const scored = mine.map((s) => ({ sym: s, score: scoreSymbol(s.name, qTokens) })).sort((a, b) => {
|
|
2090
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
2091
|
+
const aSpan = a.sym.end_line - a.sym.start_line || 1;
|
|
2092
|
+
const bSpan = b.sym.end_line - b.sym.start_line || 1;
|
|
2093
|
+
return aSpan - bSpan;
|
|
2094
|
+
});
|
|
2095
|
+
const parts = [];
|
|
2096
|
+
const inlined = [];
|
|
2097
|
+
let used = 0;
|
|
2098
|
+
for (const { sym, score: score2 } of scored) {
|
|
2099
|
+
if (score2 === 0 && inlined.length > 0) break;
|
|
2100
|
+
const body = truncate(sliceLines(file.content, sym.start_line, sym.end_line));
|
|
2101
|
+
const header = `${file.path}::${sym.name} (L${sym.start_line}-${sym.end_line})`;
|
|
2102
|
+
const block = `${header}
|
|
2103
|
+
${body}
|
|
2104
|
+
`;
|
|
2105
|
+
if (used + block.length > budgetChars) {
|
|
2106
|
+
if (inlined.length > 0) break;
|
|
2107
|
+
const remaining = Math.max(0, budgetChars - used - header.length - 16);
|
|
2108
|
+
if (remaining <= 0) break;
|
|
2109
|
+
const partial = body.slice(0, remaining).trimEnd() + "\n // \u2026 truncated";
|
|
2110
|
+
const finalBlock = `${header}
|
|
2111
|
+
${partial}
|
|
2112
|
+
`;
|
|
2113
|
+
parts.push(finalBlock);
|
|
2114
|
+
inlined.push(sym.name);
|
|
2115
|
+
used += finalBlock.length;
|
|
2116
|
+
break;
|
|
2117
|
+
}
|
|
2118
|
+
parts.push(block);
|
|
2119
|
+
inlined.push(sym.name);
|
|
2120
|
+
used += block.length;
|
|
2121
|
+
}
|
|
2122
|
+
return { text: parts.join("\n"), charsUsed: used, functionsInlined: inlined };
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
// src/packer/signatures.ts
|
|
2126
|
+
function extractSignatures(file, symbols) {
|
|
2127
|
+
const mine = symbols.filter((s) => s.file === file.path).slice().sort((a, b) => a.start_line - b.start_line);
|
|
2128
|
+
return mine.map((s) => `L${s.start_line}: ${s.signature.trim()}`);
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
// src/packer/tests.ts
|
|
2132
|
+
function findTestsForFile(graph, file) {
|
|
2133
|
+
const fileNodesById = /* @__PURE__ */ new Map();
|
|
2134
|
+
for (const n of graph.nodes) {
|
|
2135
|
+
if (n.kind === "file") fileNodesById.set(n.id, n);
|
|
2136
|
+
}
|
|
2137
|
+
const out = [];
|
|
2138
|
+
for (const e of graph.edges) {
|
|
2139
|
+
if (e.kind !== "tests" || e.to !== file.id) continue;
|
|
2140
|
+
const testFile = fileNodesById.get(e.from);
|
|
2141
|
+
if (testFile && !out.includes(testFile)) out.push(testFile);
|
|
2142
|
+
}
|
|
2143
|
+
return out;
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
// src/packer/index.ts
|
|
2147
|
+
var STATIC_OVERHEAD_PER_FILE = 200;
|
|
2148
|
+
var MAX_INLINE_CHARS_PER_FILE = 2500;
|
|
2149
|
+
function indexSymbolsByFile2(graph) {
|
|
2150
|
+
return graph.nodes.filter((n) => n.kind === "symbol");
|
|
2151
|
+
}
|
|
2152
|
+
async function pack(files, opts) {
|
|
2153
|
+
const budgetTokens = opts.budgetTokens ?? 4e3;
|
|
2154
|
+
const budgetChars = budgetTokens * 4;
|
|
2155
|
+
const inlineRatio = opts.inlineBodyRatio ?? 0.5;
|
|
2156
|
+
const includeTests = opts.includeTests ?? true;
|
|
2157
|
+
const reasons = opts.reasons ?? /* @__PURE__ */ new Map();
|
|
2158
|
+
const symbols = indexSymbolsByFile2(opts.graph);
|
|
2159
|
+
const sections = [];
|
|
2160
|
+
const testsCoRetrieved = [];
|
|
2161
|
+
let used = 0;
|
|
2162
|
+
let truncated = false;
|
|
2163
|
+
for (const file of files) {
|
|
2164
|
+
const sig = extractSignatures(file, symbols);
|
|
2165
|
+
const testFiles = includeTests ? findTestsForFile(opts.graph, file) : [];
|
|
2166
|
+
const testPaths = testFiles.map((t) => t.path);
|
|
2167
|
+
const staticCost = file.path.length + sig.join("\n").length + testPaths.join(",").length + STATIC_OVERHEAD_PER_FILE;
|
|
2168
|
+
if (used + staticCost > budgetChars) {
|
|
2169
|
+
truncated = true;
|
|
2170
|
+
break;
|
|
2171
|
+
}
|
|
2172
|
+
const remaining = budgetChars - used - staticCost;
|
|
2173
|
+
const inlineBudget = Math.min(Math.floor(remaining * inlineRatio), MAX_INLINE_CHARS_PER_FILE);
|
|
2174
|
+
const inline = selectInlineBodies(file, symbols, opts.query, inlineBudget);
|
|
2175
|
+
sections.push({
|
|
2176
|
+
path: file.path,
|
|
2177
|
+
reason: reasons.get(file.path),
|
|
2178
|
+
signatures: sig,
|
|
2179
|
+
inlineBodies: inline.text,
|
|
2180
|
+
associatedTests: testPaths
|
|
2181
|
+
});
|
|
2182
|
+
used += staticCost + inline.charsUsed;
|
|
2183
|
+
for (const t of testPaths) if (!testsCoRetrieved.includes(t)) testsCoRetrieved.push(t);
|
|
2184
|
+
if (used >= budgetChars) {
|
|
2185
|
+
truncated = true;
|
|
2186
|
+
break;
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
if (sections.length < files.length) truncated = true;
|
|
2190
|
+
const text = formatPack({
|
|
2191
|
+
query: opts.query,
|
|
2192
|
+
files: sections,
|
|
2193
|
+
truncated
|
|
2194
|
+
});
|
|
2195
|
+
const tokenEstimate = Math.ceil(text.length / 4);
|
|
2196
|
+
return {
|
|
2197
|
+
text,
|
|
2198
|
+
tokenEstimate,
|
|
2199
|
+
filesUsed: sections.map((s) => s.path),
|
|
2200
|
+
testsCoRetrieved,
|
|
2201
|
+
truncated
|
|
2202
|
+
};
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
// src/server/mcp.ts
|
|
2206
|
+
var PROTOCOL_VERSION = "2024-11-05";
|
|
2207
|
+
var SERVER_INFO = { name: "synthra", version: "0.0.1" };
|
|
2208
|
+
var ERR = {
|
|
2209
|
+
parse: -32700,
|
|
2210
|
+
invalidRequest: -32600,
|
|
2211
|
+
methodNotFound: -32601,
|
|
2212
|
+
invalidParams: -32602,
|
|
2213
|
+
internal: -32603
|
|
2214
|
+
};
|
|
2215
|
+
function ok(id, result) {
|
|
2216
|
+
return { jsonrpc: "2.0", id, result };
|
|
2217
|
+
}
|
|
2218
|
+
function err(id, code, message, data) {
|
|
2219
|
+
return { jsonrpc: "2.0", id, error: { code, message, data } };
|
|
2220
|
+
}
|
|
2221
|
+
function textContent(text) {
|
|
2222
|
+
return { content: [{ type: "text", text }], isError: false };
|
|
2223
|
+
}
|
|
2224
|
+
function errorContent(message) {
|
|
2225
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
2226
|
+
}
|
|
2227
|
+
var TOOLS = [
|
|
2228
|
+
{
|
|
2229
|
+
name: "graph_continue",
|
|
2230
|
+
description: "Returns the project context most relevant to a query \u2014 function signatures, top function bodies, and linked test files. Use this BEFORE Grep/Glob. If `confidence` is 'high', do not call Grep/Glob for the same query.",
|
|
2231
|
+
inputSchema: {
|
|
2232
|
+
type: "object",
|
|
2233
|
+
properties: {
|
|
2234
|
+
query: { type: "string", description: "Natural-language description of what you're looking for." }
|
|
2235
|
+
},
|
|
2236
|
+
required: ["query"]
|
|
2237
|
+
}
|
|
2238
|
+
},
|
|
2239
|
+
{
|
|
2240
|
+
name: "graph_read",
|
|
2241
|
+
description: "Return the source code for a specific file or symbol. Target is either a project-relative file path (e.g. 'src/auth.ts') or 'file::symbol' (e.g. 'src/auth.ts::AuthService').",
|
|
2242
|
+
inputSchema: {
|
|
2243
|
+
type: "object",
|
|
2244
|
+
properties: {
|
|
2245
|
+
target: { type: "string", description: "File path or file::symbol notation." }
|
|
2246
|
+
},
|
|
2247
|
+
required: ["target"]
|
|
2248
|
+
}
|
|
2249
|
+
},
|
|
2250
|
+
{
|
|
2251
|
+
name: "graph_register_edit",
|
|
2252
|
+
description: "Tell Synthra that you (the AI) have edited these files. Lets Synthra rank them higher in subsequent retrieval and avoid surfacing stale context.",
|
|
2253
|
+
inputSchema: {
|
|
2254
|
+
type: "object",
|
|
2255
|
+
properties: {
|
|
2256
|
+
files: {
|
|
2257
|
+
type: "array",
|
|
2258
|
+
items: { type: "string" },
|
|
2259
|
+
description: "Project-relative file paths that were edited."
|
|
2260
|
+
}
|
|
2261
|
+
},
|
|
2262
|
+
required: ["files"]
|
|
2263
|
+
}
|
|
2264
|
+
},
|
|
2265
|
+
{
|
|
2266
|
+
name: "context_remember",
|
|
2267
|
+
description: "Persist a decision/task/next-step/fact/blocker into the project's branch-aware context store. Use when the user makes a decision worth keeping, identifies a TODO, or surfaces a key fact. Entries land in `.synthra/context-store.json` on the default branch, or `.synthra/branches/<sanitized>/context-store.json` on a feature branch \u2014 git-tracked, so teammates inherit them and they merge naturally.",
|
|
2268
|
+
inputSchema: {
|
|
2269
|
+
type: "object",
|
|
2270
|
+
properties: {
|
|
2271
|
+
text: { type: "string", description: "The thing to remember (1\u20133 sentences)." },
|
|
2272
|
+
kind: {
|
|
2273
|
+
type: "string",
|
|
2274
|
+
enum: ["decision", "task", "next", "fact", "blocker"],
|
|
2275
|
+
description: "What kind of entry. Required."
|
|
2276
|
+
},
|
|
2277
|
+
tags: {
|
|
2278
|
+
type: "array",
|
|
2279
|
+
items: { type: "string" },
|
|
2280
|
+
description: "Optional tags for grouping (e.g. 'auth', 'perf')."
|
|
2281
|
+
},
|
|
2282
|
+
files: {
|
|
2283
|
+
type: "array",
|
|
2284
|
+
items: { type: "string" },
|
|
2285
|
+
description: "Optional project-relative file paths this entry relates to."
|
|
2286
|
+
}
|
|
2287
|
+
},
|
|
2288
|
+
required: ["text", "kind"]
|
|
2289
|
+
}
|
|
2290
|
+
},
|
|
2291
|
+
{
|
|
2292
|
+
name: "context_recall",
|
|
2293
|
+
description: "Read previously-stored decisions/tasks/facts from the project's branch-aware context store. Defaults to the current branch.",
|
|
2294
|
+
inputSchema: {
|
|
2295
|
+
type: "object",
|
|
2296
|
+
properties: {
|
|
2297
|
+
kind: {
|
|
2298
|
+
type: "string",
|
|
2299
|
+
enum: ["decision", "task", "next", "fact", "blocker"],
|
|
2300
|
+
description: "Filter to a single kind."
|
|
2301
|
+
},
|
|
2302
|
+
branch: { type: "string", description: "Override which branch to read from." },
|
|
2303
|
+
limit: { type: "number", description: "Return only the most recent N entries." }
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
},
|
|
2307
|
+
{
|
|
2308
|
+
name: "recent_activity",
|
|
2309
|
+
description: "What has the human been doing in the editor recently \u2014 file saves, branch switches, and uncommitted-diff changes. Use this to check whether the static context pack may be stale (e.g. before answering a question about a file that was just edited).",
|
|
2310
|
+
inputSchema: {
|
|
2311
|
+
type: "object",
|
|
2312
|
+
properties: {
|
|
2313
|
+
since_ms: {
|
|
2314
|
+
type: "number",
|
|
2315
|
+
description: "Epoch milliseconds. Only return events newer than this. Defaults to the last 60 minutes."
|
|
2316
|
+
},
|
|
2317
|
+
limit: { type: "number", description: "Cap on returned events." }
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
},
|
|
2321
|
+
{
|
|
2322
|
+
name: "count_tokens",
|
|
2323
|
+
description: "Estimate token count for a piece of text using a char/4 approximation. Accurate within ~10% for English + code. Useful for budgeting prompt content before sending.",
|
|
2324
|
+
inputSchema: {
|
|
2325
|
+
type: "object",
|
|
2326
|
+
properties: {
|
|
2327
|
+
text: { type: "string", description: "The text to estimate tokens for." }
|
|
2328
|
+
},
|
|
2329
|
+
required: ["text"]
|
|
2330
|
+
}
|
|
2331
|
+
},
|
|
2332
|
+
{
|
|
2333
|
+
name: "blast_radius",
|
|
2334
|
+
description: "Given a file (or 'file::symbol' target), return all files that depend on it transitively via imports + tests edges. Use BEFORE editing a widely-used file to see what could break. Symbol-level granularity is approximated at the file level (we don't track call edges in v0.1).",
|
|
2335
|
+
inputSchema: {
|
|
2336
|
+
type: "object",
|
|
2337
|
+
properties: {
|
|
2338
|
+
target: { type: "string", description: "File path or 'file::symbol' notation." },
|
|
2339
|
+
depth: { type: "number", description: "Max hops to traverse. Default 3." }
|
|
2340
|
+
},
|
|
2341
|
+
required: ["target"]
|
|
2342
|
+
}
|
|
2343
|
+
},
|
|
2344
|
+
{
|
|
2345
|
+
name: "dead_code",
|
|
2346
|
+
description: "Return files in the project that no other file imports and no test file references \u2014 strong candidates for unused/orphaned code. File-level granularity (v0.1 limitation \u2014 symbol-level needs call-graph edges). Common entry-point patterns (main, index, app, CLI, bin/) are excluded heuristically.",
|
|
2347
|
+
inputSchema: {
|
|
2348
|
+
type: "object",
|
|
2349
|
+
properties: {
|
|
2350
|
+
limit: { type: "number", description: "Cap on returned files. Default 50." }
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
];
|
|
2355
|
+
async function callTool(name, args, ctx) {
|
|
2356
|
+
switch (name) {
|
|
2357
|
+
case "graph_continue":
|
|
2358
|
+
return graphContinue(args, ctx);
|
|
2359
|
+
case "graph_read":
|
|
2360
|
+
return graphRead(args, ctx);
|
|
2361
|
+
case "graph_register_edit":
|
|
2362
|
+
return graphRegisterEdit(args, ctx);
|
|
2363
|
+
case "context_remember":
|
|
2364
|
+
return contextRemember(args, ctx);
|
|
2365
|
+
case "context_recall":
|
|
2366
|
+
return contextRecall(args, ctx);
|
|
2367
|
+
case "recent_activity":
|
|
2368
|
+
return recentActivity(args, ctx);
|
|
2369
|
+
case "count_tokens":
|
|
2370
|
+
return countTokens(args);
|
|
2371
|
+
case "blast_radius":
|
|
2372
|
+
return blastRadius(args, ctx);
|
|
2373
|
+
case "dead_code":
|
|
2374
|
+
return deadCode(args, ctx);
|
|
2375
|
+
default:
|
|
2376
|
+
return errorContent(`Unknown tool: ${name}`);
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
function countTokens(args) {
|
|
2380
|
+
const text = typeof args?.text === "string" ? args.text : "";
|
|
2381
|
+
if (!text) return errorContent("count_tokens: 'text' (string) is required");
|
|
2382
|
+
const tokens = Math.ceil(text.length / 4);
|
|
2383
|
+
return textContent(JSON.stringify({ tokens, method: "chars/4 estimate", chars: text.length }));
|
|
2384
|
+
}
|
|
2385
|
+
function blastRadius(args, ctx) {
|
|
2386
|
+
const targetRaw = typeof args?.target === "string" ? args.target.trim() : "";
|
|
2387
|
+
const maxDepth = typeof args?.depth === "number" && args.depth > 0 ? Math.floor(args.depth) : 3;
|
|
2388
|
+
if (!targetRaw) return errorContent("blast_radius: 'target' (string) is required");
|
|
2389
|
+
const filePath = targetRaw.split("::", 1)[0]?.trim() ?? targetRaw;
|
|
2390
|
+
const root = ctx.graph.nodes.find(
|
|
2391
|
+
(n) => n.kind === "file" && n.path === filePath
|
|
2392
|
+
);
|
|
2393
|
+
if (!root) return errorContent(`blast_radius: file not in graph: ${filePath}`);
|
|
2394
|
+
const incoming = /* @__PURE__ */ new Map();
|
|
2395
|
+
for (const e of ctx.graph.edges) {
|
|
2396
|
+
if (e.kind !== "imports" && e.kind !== "tests") continue;
|
|
2397
|
+
const list = incoming.get(e.to) ?? [];
|
|
2398
|
+
list.push({ from: e.from, kind: e.kind });
|
|
2399
|
+
incoming.set(e.to, list);
|
|
2400
|
+
}
|
|
2401
|
+
const visited = /* @__PURE__ */ new Set([root.id]);
|
|
2402
|
+
const hits = [];
|
|
2403
|
+
const pathById = /* @__PURE__ */ new Map();
|
|
2404
|
+
for (const n of ctx.graph.nodes) if (n.kind === "file") pathById.set(n.id, n.path);
|
|
2405
|
+
let frontier = [root.id];
|
|
2406
|
+
for (let d = 1; d <= maxDepth; d++) {
|
|
2407
|
+
const next = [];
|
|
2408
|
+
for (const cur of frontier) {
|
|
2409
|
+
const callers = incoming.get(cur) ?? [];
|
|
2410
|
+
for (const c of callers) {
|
|
2411
|
+
if (visited.has(c.from)) continue;
|
|
2412
|
+
visited.add(c.from);
|
|
2413
|
+
next.push(c.from);
|
|
2414
|
+
const path = pathById.get(c.from) ?? c.from;
|
|
2415
|
+
hits.push({ path, depth: d, via: c.kind });
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
frontier = next;
|
|
2419
|
+
if (next.length === 0) break;
|
|
2420
|
+
}
|
|
2421
|
+
if (hits.length === 0) {
|
|
2422
|
+
return textContent(`# Blast radius for ${filePath}
|
|
2423
|
+
|
|
2424
|
+
_(no dependents \u2014 file is isolated)_`);
|
|
2425
|
+
}
|
|
2426
|
+
hits.sort((a, b) => a.depth - b.depth || a.path.localeCompare(b.path));
|
|
2427
|
+
const lines = [`# Blast radius for ${filePath} (depth \u2264 ${maxDepth})`, ""];
|
|
2428
|
+
lines.push(`${hits.length} dependent file(s):`);
|
|
2429
|
+
for (const h of hits) {
|
|
2430
|
+
lines.push(`- **depth ${h.depth}** \`${h.path}\` _(via ${h.via})_`);
|
|
2431
|
+
}
|
|
2432
|
+
return textContent(lines.join("\n"));
|
|
2433
|
+
}
|
|
2434
|
+
var LIKELY_ENTRY_PATTERNS = [
|
|
2435
|
+
/(?:^|\/)main\.[a-z0-9_]+$/i,
|
|
2436
|
+
/(?:^|\/)index\.[a-z0-9_]+$/i,
|
|
2437
|
+
/(?:^|\/)app\.[a-z0-9_]+$/i,
|
|
2438
|
+
/(?:^|\/)entry\.[a-z0-9_]+$/i,
|
|
2439
|
+
/(?:^|\/)cli[\/.]/i,
|
|
2440
|
+
/(?:^|\/)bin[\/.]/i,
|
|
2441
|
+
/(?:^|\/)server\.[a-z0-9_]+$/i,
|
|
2442
|
+
/\.test\.[a-z0-9_]+$/i,
|
|
2443
|
+
/\.spec\.[a-z0-9_]+$/i,
|
|
2444
|
+
/(?:^|\/)tests?\//i,
|
|
2445
|
+
/(?:^|\/)__tests__\//i,
|
|
2446
|
+
/(?:^|\/)__init__\.py$/i
|
|
2447
|
+
];
|
|
2448
|
+
function isLikelyEntry(path) {
|
|
2449
|
+
return LIKELY_ENTRY_PATTERNS.some((re) => re.test(path));
|
|
2450
|
+
}
|
|
2451
|
+
function deadCode(args, ctx) {
|
|
2452
|
+
const limit = typeof args?.limit === "number" && args.limit > 0 ? Math.floor(args.limit) : 50;
|
|
2453
|
+
const hasIncoming = /* @__PURE__ */ new Set();
|
|
2454
|
+
for (const e of ctx.graph.edges) {
|
|
2455
|
+
if (e.kind === "imports" || e.kind === "tests") hasIncoming.add(e.to);
|
|
2456
|
+
}
|
|
2457
|
+
const candidates = ctx.graph.nodes.filter((n) => n.kind === "file").filter((f) => !hasIncoming.has(f.id)).filter((f) => !isLikelyEntry(f.path));
|
|
2458
|
+
if (candidates.length === 0) {
|
|
2459
|
+
return textContent(
|
|
2460
|
+
`# Dead code
|
|
2461
|
+
|
|
2462
|
+
_(no file is unreferenced \u2014 every file is either imported by another, has a linked test, or matches an entry-point pattern)_`
|
|
2463
|
+
);
|
|
2464
|
+
}
|
|
2465
|
+
candidates.sort((a, b) => a.path.localeCompare(b.path));
|
|
2466
|
+
const shown = candidates.slice(0, limit);
|
|
2467
|
+
const lines = [`# Dead code candidates (file-level, v0.1)`, ""];
|
|
2468
|
+
lines.push(
|
|
2469
|
+
`${shown.length} of ${candidates.length} unreferenced file(s) \u2014 no other file imports them and no test links them:`
|
|
2470
|
+
);
|
|
2471
|
+
lines.push("");
|
|
2472
|
+
for (const f of shown) {
|
|
2473
|
+
lines.push(`- \`${f.path}\``);
|
|
2474
|
+
}
|
|
2475
|
+
lines.push("");
|
|
2476
|
+
lines.push(
|
|
2477
|
+
`_v0.1 caveat:_ this is file-level only. Symbol-level dead code (unused exports) needs call-graph edges, which land in v0.2.`
|
|
2478
|
+
);
|
|
2479
|
+
return textContent(lines.join("\n"));
|
|
2480
|
+
}
|
|
2481
|
+
async function graphContinue(args, ctx) {
|
|
2482
|
+
const query = typeof args?.query === "string" ? args.query : "";
|
|
2483
|
+
if (!query) return errorContent("graph_continue: 'query' (string) is required");
|
|
2484
|
+
const retrieval = await retrieve(ctx.graph, query);
|
|
2485
|
+
const packed = await pack(retrieval.files, { query, graph: ctx.graph });
|
|
2486
|
+
const header = `Confidence: ${retrieval.confidence}
|
|
2487
|
+
Files: ${retrieval.files.map((f) => f.path).join(", ") || "(none)"}
|
|
2488
|
+
Reason: ${retrieval.reason}
|
|
2489
|
+
`;
|
|
2490
|
+
return textContent(`${header}
|
|
2491
|
+
${packed.text}`);
|
|
2492
|
+
}
|
|
2493
|
+
function graphRead(args, ctx) {
|
|
2494
|
+
const target = typeof args?.target === "string" ? args.target : "";
|
|
2495
|
+
if (!target) return errorContent("graph_read: 'target' (string) is required");
|
|
2496
|
+
const [rawFile, symbolName] = target.includes("::") ? target.split("::", 2) : [target, void 0];
|
|
2497
|
+
const filePath = (rawFile ?? "").trim();
|
|
2498
|
+
const fileNode = ctx.graph.nodes.find(
|
|
2499
|
+
(n) => n.kind === "file" && n.path === filePath
|
|
2500
|
+
);
|
|
2501
|
+
if (!fileNode) return errorContent(`graph_read: file not found in graph: ${filePath}`);
|
|
2502
|
+
if (!symbolName) {
|
|
2503
|
+
return textContent(`# ${fileNode.path}
|
|
2504
|
+
|
|
2505
|
+
${fileNode.content}`);
|
|
2506
|
+
}
|
|
2507
|
+
const cleanSym = symbolName.trim();
|
|
2508
|
+
const symbol = ctx.graph.nodes.find(
|
|
2509
|
+
(n) => n.kind === "symbol" && n.file === filePath && n.name === cleanSym
|
|
2510
|
+
);
|
|
2511
|
+
if (!symbol) {
|
|
2512
|
+
return errorContent(`graph_read: symbol '${cleanSym}' not found in ${filePath}`);
|
|
2513
|
+
}
|
|
2514
|
+
const lines = fileNode.content.split(/\r?\n/);
|
|
2515
|
+
const body = lines.slice(symbol.start_line - 1, symbol.end_line).join("\n");
|
|
2516
|
+
return textContent(
|
|
2517
|
+
`# ${fileNode.path}::${symbol.name} (L${symbol.start_line}-${symbol.end_line})
|
|
2518
|
+
|
|
2519
|
+
${body}`
|
|
2520
|
+
);
|
|
2521
|
+
}
|
|
2522
|
+
var editedFiles = /* @__PURE__ */ new Set();
|
|
2523
|
+
function graphRegisterEdit(args, _ctx) {
|
|
2524
|
+
const files = Array.isArray(args?.files) ? args.files.filter((f) => typeof f === "string") : [];
|
|
2525
|
+
for (const f of files) editedFiles.add(f);
|
|
2526
|
+
return textContent(`Registered ${files.length} edited file(s). Total tracked this session: ${editedFiles.size}.`);
|
|
2527
|
+
}
|
|
2528
|
+
var VALID_KINDS = /* @__PURE__ */ new Set(["decision", "task", "next", "fact", "blocker"]);
|
|
2529
|
+
async function contextRemember(args, ctx) {
|
|
2530
|
+
const text = typeof args?.text === "string" ? args.text.trim() : "";
|
|
2531
|
+
const kindRaw = typeof args?.kind === "string" ? args.kind : "";
|
|
2532
|
+
if (!text) return errorContent("context_remember: 'text' (string) is required");
|
|
2533
|
+
if (!VALID_KINDS.has(kindRaw)) {
|
|
2534
|
+
return errorContent(
|
|
2535
|
+
`context_remember: 'kind' must be one of ${Array.from(VALID_KINDS).join(", ")}`
|
|
2536
|
+
);
|
|
2537
|
+
}
|
|
2538
|
+
const tags = Array.isArray(args?.tags) ? args.tags.filter((t) => typeof t === "string") : [];
|
|
2539
|
+
const files = Array.isArray(args?.files) ? args.files.filter((f) => typeof f === "string") : [];
|
|
2540
|
+
const result = await rememberEntry(ctx.paths, {
|
|
2541
|
+
text,
|
|
2542
|
+
kind: kindRaw,
|
|
2543
|
+
tags,
|
|
2544
|
+
files
|
|
2545
|
+
});
|
|
2546
|
+
return textContent(
|
|
2547
|
+
`Remembered ${result.entry.type} on branch '${result.branch}'.
|
|
2548
|
+
Stored: ${result.storePath}
|
|
2549
|
+
CONTEXT.md refreshed: ${result.contextMdPath}`
|
|
2550
|
+
);
|
|
2551
|
+
}
|
|
2552
|
+
var DEFAULT_RECENT_WINDOW_MS = 60 * 60 * 1e3;
|
|
2553
|
+
function recentActivity(args, ctx) {
|
|
2554
|
+
const sinceMs = typeof args?.since_ms === "number" && Number.isFinite(args.since_ms) ? args.since_ms : Date.now() - DEFAULT_RECENT_WINDOW_MS;
|
|
2555
|
+
const limit = typeof args?.limit === "number" && args.limit > 0 ? Math.floor(args.limit) : void 0;
|
|
2556
|
+
let events = ctx.activity.getEvents(sinceMs);
|
|
2557
|
+
if (limit) events = events.slice(-limit);
|
|
2558
|
+
if (events.length === 0) {
|
|
2559
|
+
return textContent(
|
|
2560
|
+
`No human-activity events since ${new Date(sinceMs).toISOString()}.`
|
|
2561
|
+
);
|
|
2562
|
+
}
|
|
2563
|
+
const lines = [`# Recent human activity (${events.length} events)`, ""];
|
|
2564
|
+
for (const e of events) {
|
|
2565
|
+
if ("path" in e) {
|
|
2566
|
+
lines.push(`- **${e.kind}** ${e.path} _(${e.ts})_`);
|
|
2567
|
+
} else {
|
|
2568
|
+
const summary = JSON.stringify(e.details);
|
|
2569
|
+
lines.push(`- **${e.kind}** ${summary} _(${e.ts})_`);
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
return textContent(lines.join("\n"));
|
|
2573
|
+
}
|
|
2574
|
+
async function contextRecall(args, ctx) {
|
|
2575
|
+
const kind = typeof args?.kind === "string" && VALID_KINDS.has(args.kind) ? args.kind : void 0;
|
|
2576
|
+
const branch = typeof args?.branch === "string" ? args.branch : void 0;
|
|
2577
|
+
const limit = typeof args?.limit === "number" && args.limit > 0 ? Math.floor(args.limit) : void 0;
|
|
2578
|
+
const result = await recallEntries(ctx.paths, { kind, branch, limit });
|
|
2579
|
+
if (result.entries.length === 0) {
|
|
2580
|
+
const filter = kind ? ` of kind '${kind}'` : "";
|
|
2581
|
+
return textContent(`No context entries${filter} on branch '${result.branch}'.`);
|
|
2582
|
+
}
|
|
2583
|
+
const lines = [`# Context entries \u2014 branch: ${result.branch}`, ""];
|
|
2584
|
+
for (const e of result.entries) {
|
|
2585
|
+
const tags = e.tags.length ? ` [${e.tags.join(", ")}]` : "";
|
|
2586
|
+
lines.push(`- **${e.type}**${tags} (${e.date}): ${e.content}`);
|
|
2587
|
+
if (e.files.length) lines.push(` files: ${e.files.join(", ")}`);
|
|
2588
|
+
}
|
|
2589
|
+
return textContent(lines.join("\n"));
|
|
2590
|
+
}
|
|
2591
|
+
async function handleMcpRequest(body, ctx) {
|
|
2592
|
+
if (!body || typeof body !== "object") {
|
|
2593
|
+
return err(null, ERR.invalidRequest, "Request body must be a JSON-RPC 2.0 object.");
|
|
2594
|
+
}
|
|
2595
|
+
const req = body;
|
|
2596
|
+
if (req.jsonrpc !== "2.0" || typeof req.method !== "string") {
|
|
2597
|
+
return err(req.id ?? null, ERR.invalidRequest, "Invalid JSON-RPC envelope.");
|
|
2598
|
+
}
|
|
2599
|
+
const id = req.id ?? null;
|
|
2600
|
+
try {
|
|
2601
|
+
switch (req.method) {
|
|
2602
|
+
case "initialize":
|
|
2603
|
+
return ok(id, {
|
|
2604
|
+
protocolVersion: typeof req.params?.protocolVersion === "string" ? req.params.protocolVersion : PROTOCOL_VERSION,
|
|
2605
|
+
capabilities: { tools: {} },
|
|
2606
|
+
serverInfo: SERVER_INFO
|
|
2607
|
+
});
|
|
2608
|
+
case "notifications/initialized":
|
|
2609
|
+
return ok(id, {});
|
|
2610
|
+
case "tools/list":
|
|
2611
|
+
return ok(id, { tools: TOOLS });
|
|
2612
|
+
case "tools/call": {
|
|
2613
|
+
const params = req.params ?? {};
|
|
2614
|
+
const toolName = typeof params.name === "string" ? params.name : "";
|
|
2615
|
+
if (!toolName) return err(id, ERR.invalidParams, "'name' is required for tools/call.");
|
|
2616
|
+
const args = params.arguments && typeof params.arguments === "object" ? params.arguments : {};
|
|
2617
|
+
const result = await callTool(toolName, args, ctx);
|
|
2618
|
+
return ok(id, result);
|
|
2619
|
+
}
|
|
2620
|
+
case "ping":
|
|
2621
|
+
return ok(id, {});
|
|
2622
|
+
default:
|
|
2623
|
+
return err(id, ERR.methodNotFound, `Method not found: ${req.method}`);
|
|
2624
|
+
}
|
|
2625
|
+
} catch (e) {
|
|
2626
|
+
return err(id, ERR.internal, e.message);
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
// src/server/port.ts
|
|
2631
|
+
import { createServer } from "net";
|
|
2632
|
+
var PORT_RANGE_START = 8080;
|
|
2633
|
+
var PORT_RANGE_END = 8099;
|
|
2634
|
+
async function findFreePort(start = PORT_RANGE_START, end = PORT_RANGE_END) {
|
|
2635
|
+
for (let port = start; port <= end; port++) {
|
|
2636
|
+
if (await isFree(port)) return port;
|
|
2637
|
+
}
|
|
2638
|
+
throw new Error(`Synthra: no free port in ${start}-${end}`);
|
|
2639
|
+
}
|
|
2640
|
+
function isFree(port) {
|
|
2641
|
+
return new Promise((resolve2) => {
|
|
2642
|
+
const s = createServer();
|
|
2643
|
+
s.once("error", () => resolve2(false));
|
|
2644
|
+
s.once("listening", () => s.close(() => resolve2(true)));
|
|
2645
|
+
s.listen(port, "127.0.0.1");
|
|
2646
|
+
});
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
// src/server/routes/activity.ts
|
|
2650
|
+
async function handleActivity(sinceMs, ctx) {
|
|
2651
|
+
const events = ctx.activity.getEvents(sinceMs);
|
|
2652
|
+
return {
|
|
2653
|
+
events,
|
|
2654
|
+
since: new Date(sinceMs ?? Date.now()).toISOString(),
|
|
2655
|
+
ring_size: ctx.activity.size()
|
|
2656
|
+
};
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
// src/server/routes/context-update.ts
|
|
2660
|
+
async function handleContextUpdate(req, ctx) {
|
|
2661
|
+
const r = await refreshContextMd(ctx.paths, req?.branch);
|
|
2662
|
+
return {
|
|
2663
|
+
updated: true,
|
|
2664
|
+
branch: r.branch,
|
|
2665
|
+
path: r.path,
|
|
2666
|
+
entries: r.entriesSeen
|
|
2667
|
+
};
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
// src/server/routes/gate.ts
|
|
2671
|
+
import { appendFile as appendFile2, mkdir as mkdir6 } from "fs/promises";
|
|
2672
|
+
import { dirname as dirname6 } from "path";
|
|
2673
|
+
var BLOCKABLE_TOOLS = /* @__PURE__ */ new Set(["Grep", "Glob"]);
|
|
2674
|
+
var RECENT_ACTIVITY_WINDOW_MS = 5 * 60 * 1e3;
|
|
2675
|
+
function extractQuery(toolName, input) {
|
|
2676
|
+
if (toolName === "Grep") {
|
|
2677
|
+
const pattern = typeof input.pattern === "string" ? input.pattern : "";
|
|
2678
|
+
const query = typeof input.query === "string" ? input.query : "";
|
|
2679
|
+
return (pattern || query).trim() || null;
|
|
2680
|
+
}
|
|
2681
|
+
if (toolName === "Glob") {
|
|
2682
|
+
const pattern = typeof input.pattern === "string" ? input.pattern : "";
|
|
2683
|
+
return pattern.replace(/[*?/\\.]+/g, " ").trim() || null;
|
|
2684
|
+
}
|
|
2685
|
+
return null;
|
|
2686
|
+
}
|
|
2687
|
+
function recentlyTouchedMatchesQuery(recentPaths, queryTokens) {
|
|
2688
|
+
const matches = [];
|
|
2689
|
+
for (const path of recentPaths) {
|
|
2690
|
+
const lower = path.toLowerCase();
|
|
2691
|
+
for (const t of queryTokens) {
|
|
2692
|
+
if (lower.includes(t)) {
|
|
2693
|
+
matches.push(path);
|
|
2694
|
+
break;
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
return matches;
|
|
2699
|
+
}
|
|
2700
|
+
async function logDecision(ctx, toolName, query, decision, reason) {
|
|
2701
|
+
try {
|
|
2702
|
+
await mkdir6(dirname6(ctx.paths.gateLog), { recursive: true });
|
|
2703
|
+
const entry = {
|
|
2704
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2705
|
+
tool: toolName,
|
|
2706
|
+
decision,
|
|
2707
|
+
query,
|
|
2708
|
+
reason
|
|
2709
|
+
};
|
|
2710
|
+
await appendFile2(ctx.paths.gateLog, JSON.stringify(entry) + "\n", "utf8");
|
|
2711
|
+
} catch {
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
async function handleGate(req, ctx) {
|
|
2715
|
+
if (!req?.tool_name || typeof req.tool_name !== "string") {
|
|
2716
|
+
return { decision: "allow", reason: "no tool_name" };
|
|
2717
|
+
}
|
|
2718
|
+
if (!BLOCKABLE_TOOLS.has(req.tool_name)) {
|
|
2719
|
+
return { decision: "allow" };
|
|
2720
|
+
}
|
|
2721
|
+
const input = req.tool_input && typeof req.tool_input === "object" ? req.tool_input : {};
|
|
2722
|
+
const query = extractQuery(req.tool_name, input);
|
|
2723
|
+
if (!query) {
|
|
2724
|
+
const res2 = { decision: "allow", reason: "no extractable query" };
|
|
2725
|
+
await logDecision(ctx, req.tool_name, null, res2.decision, res2.reason);
|
|
2726
|
+
return res2;
|
|
2727
|
+
}
|
|
2728
|
+
const retrieval = await retrieve(ctx.graph, query);
|
|
2729
|
+
if (retrieval.confidence === "low") {
|
|
2730
|
+
const res2 = {
|
|
2731
|
+
decision: "allow",
|
|
2732
|
+
reason: `confidence=low \u2014 no graph context for "${query}", letting ${req.tool_name} through`
|
|
2733
|
+
};
|
|
2734
|
+
await logDecision(ctx, req.tool_name, query, res2.decision, res2.reason);
|
|
2735
|
+
return res2;
|
|
2736
|
+
}
|
|
2737
|
+
const qTokens = new Set(tokenizeQuery(query));
|
|
2738
|
+
const recentPaths = ctx.activity.recentFilePaths(RECENT_ACTIVITY_WINDOW_MS);
|
|
2739
|
+
const overlap = recentlyTouchedMatchesQuery(recentPaths, qTokens);
|
|
2740
|
+
if (overlap.length > 0) {
|
|
2741
|
+
const res2 = {
|
|
2742
|
+
decision: "allow",
|
|
2743
|
+
reason: `confidence=${retrieval.confidence} but human just touched ${overlap.slice(0, 3).join(", ")} \u2014 static context may be stale, letting ${req.tool_name} through.`
|
|
2744
|
+
};
|
|
2745
|
+
await logDecision(ctx, req.tool_name, query, res2.decision, res2.reason);
|
|
2746
|
+
return res2;
|
|
2747
|
+
}
|
|
2748
|
+
const top = retrieval.files.slice(0, 3).map((f) => f.path).join(", ");
|
|
2749
|
+
const res = {
|
|
2750
|
+
decision: "block",
|
|
2751
|
+
reason: `Synthra has ${retrieval.confidence}-confidence context for "${query}" (top files: ${top}). Use the \`graph_continue\` MCP tool with this query instead of ${req.tool_name}, or read a specific file/symbol with \`graph_read\`.`
|
|
2752
|
+
};
|
|
2753
|
+
await logDecision(ctx, req.tool_name, query, res.decision, res.reason);
|
|
2754
|
+
return res;
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
// src/server/routes/log.ts
|
|
2758
|
+
import { appendFile as appendFile3, mkdir as mkdir7 } from "fs/promises";
|
|
2759
|
+
import { dirname as dirname7 } from "path";
|
|
2760
|
+
async function handleLog(entry, ctx) {
|
|
2761
|
+
if (!entry || typeof entry.input_tokens !== "number" || typeof entry.output_tokens !== "number") {
|
|
2762
|
+
throw new Error("log: input_tokens and output_tokens (number) are required");
|
|
2763
|
+
}
|
|
2764
|
+
const written_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
2765
|
+
const record = { ...entry, written_at };
|
|
2766
|
+
await mkdir7(dirname7(ctx.paths.tokenLog), { recursive: true });
|
|
2767
|
+
await appendFile3(ctx.paths.tokenLog, JSON.stringify(record) + "\n", "utf8");
|
|
2768
|
+
return { ok: true, written_at };
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
// src/server/routes/pack.ts
|
|
2772
|
+
async function handlePack(req, ctx) {
|
|
2773
|
+
if (!req?.query || typeof req.query !== "string") {
|
|
2774
|
+
throw new Error("pack: 'query' (string) is required");
|
|
2775
|
+
}
|
|
2776
|
+
const recentlyEditedPaths = ctx.activity.recentFilePaths(15 * 60 * 1e3);
|
|
2777
|
+
const retrieval = await retrieve(ctx.graph, req.query, { recentlyEditedPaths });
|
|
2778
|
+
const allFiles = ctx.graph.nodes.filter((n) => n.kind === "file");
|
|
2779
|
+
const scored = scoreFiles({
|
|
2780
|
+
candidates: allFiles,
|
|
2781
|
+
query: req.query,
|
|
2782
|
+
graph: ctx.graph,
|
|
2783
|
+
recentlyEditedPaths
|
|
2784
|
+
});
|
|
2785
|
+
const reasons = /* @__PURE__ */ new Map();
|
|
2786
|
+
for (const s of scored) {
|
|
2787
|
+
if (s.reasons.length) reasons.set(s.file.path, s.reasons.join(","));
|
|
2788
|
+
}
|
|
2789
|
+
const result = await pack(retrieval.files, {
|
|
2790
|
+
query: req.query,
|
|
2791
|
+
graph: ctx.graph,
|
|
2792
|
+
budgetTokens: req.maxTokens,
|
|
2793
|
+
includeTests: req.includeTests,
|
|
2794
|
+
reasons
|
|
2795
|
+
});
|
|
2796
|
+
return {
|
|
2797
|
+
...result,
|
|
2798
|
+
query: req.query,
|
|
2799
|
+
confidence: retrieval.confidence,
|
|
2800
|
+
retrievalReason: retrieval.reason
|
|
2801
|
+
};
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
// src/server/routes/prime.ts
|
|
2805
|
+
async function handlePrime(ctx, port) {
|
|
2806
|
+
const g = ctx.graph;
|
|
2807
|
+
const fileCount = g.file_count;
|
|
2808
|
+
const symbolCount = g.symbol_count;
|
|
2809
|
+
const primer = `Synthra context loaded for ${g.root}.
|
|
2810
|
+
${fileCount} files indexed, ${symbolCount} symbols. Prefer the graph_* MCP tools over Grep/Glob for navigation.
|
|
2811
|
+
(Full primer wired in M3.)`;
|
|
2812
|
+
return { primer, port };
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
// src/server/http.ts
|
|
2816
|
+
async function loadContext(paths) {
|
|
2817
|
+
try {
|
|
2818
|
+
const [graph, symbolIndex] = await Promise.all([
|
|
2819
|
+
readGraph(paths.infoGraph),
|
|
2820
|
+
readSymbolIndex(paths.symbolIndex)
|
|
2821
|
+
]);
|
|
2822
|
+
const activity = new ActivityStore(paths.activityLog);
|
|
2823
|
+
return { paths, graph, symbolIndex, activity };
|
|
2824
|
+
} catch (err2) {
|
|
2825
|
+
throw new Error(
|
|
2826
|
+
`failed to load graph from ${paths.infoGraph}: ${err2.message}. Run \`syn scan\` first.`
|
|
2827
|
+
);
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
function buildApp(ctx, port) {
|
|
2831
|
+
const app = new Hono();
|
|
2832
|
+
app.get(
|
|
2833
|
+
"/",
|
|
2834
|
+
(c) => c.json({
|
|
2835
|
+
service: "synthra",
|
|
2836
|
+
version: "0.0.1",
|
|
2837
|
+
port,
|
|
2838
|
+
file_count: ctx.graph.file_count,
|
|
2839
|
+
symbol_count: ctx.graph.symbol_count,
|
|
2840
|
+
generated_at: ctx.graph.generated_at
|
|
2841
|
+
})
|
|
2842
|
+
);
|
|
2843
|
+
app.get("/health", (c) => c.json({ ok: true }));
|
|
2844
|
+
app.get("/prime", async (c) => c.json(await handlePrime(ctx, port)));
|
|
2845
|
+
app.post("/pack", async (c) => {
|
|
2846
|
+
const body = await c.req.json().catch(() => ({}));
|
|
2847
|
+
return c.json(await handlePack(body, ctx));
|
|
2848
|
+
});
|
|
2849
|
+
app.post("/log", async (c) => {
|
|
2850
|
+
const body = await c.req.json().catch(() => ({}));
|
|
2851
|
+
return c.json(await handleLog(body, ctx));
|
|
2852
|
+
});
|
|
2853
|
+
app.post("/gate", async (c) => {
|
|
2854
|
+
const body = await c.req.json().catch(() => ({}));
|
|
2855
|
+
return c.json(await handleGate(body, ctx));
|
|
2856
|
+
});
|
|
2857
|
+
app.get("/activity", async (c) => {
|
|
2858
|
+
const sinceParam = c.req.query("since");
|
|
2859
|
+
const sinceMs = sinceParam ? Number(sinceParam) : void 0;
|
|
2860
|
+
return c.json(
|
|
2861
|
+
await handleActivity(Number.isFinite(sinceMs) ? sinceMs : void 0, ctx)
|
|
2862
|
+
);
|
|
2863
|
+
});
|
|
2864
|
+
app.post("/context-update", async (c) => {
|
|
2865
|
+
const body = await c.req.json().catch(() => ({}));
|
|
2866
|
+
return c.json(await handleContextUpdate(body, ctx));
|
|
2867
|
+
});
|
|
2868
|
+
app.post("/mcp", async (c) => {
|
|
2869
|
+
const body = await c.req.json().catch(() => null);
|
|
2870
|
+
return c.json(await handleMcpRequest(body, ctx));
|
|
2871
|
+
});
|
|
2872
|
+
app.onError((err2, c) => {
|
|
2873
|
+
log.error("route error:", err2.message);
|
|
2874
|
+
return c.json({ error: err2.message }, 400);
|
|
2875
|
+
});
|
|
2876
|
+
return app;
|
|
2877
|
+
}
|
|
2878
|
+
async function startServer(paths, options = {}) {
|
|
2879
|
+
const ctx = await loadContext(paths);
|
|
2880
|
+
const port = options.port ?? await findFreePort();
|
|
2881
|
+
const app = buildApp(ctx, port);
|
|
2882
|
+
const nodeServer = serve({ fetch: app.fetch, port, hostname: "127.0.0.1" });
|
|
2883
|
+
await writeFile6(paths.mcpPort, String(port), "utf8");
|
|
2884
|
+
const fileWatcher = createFileWatcher(
|
|
2885
|
+
paths.projectRoot,
|
|
2886
|
+
(e) => ctx.activity.add(e)
|
|
2887
|
+
);
|
|
2888
|
+
const gitWatcher = createGitWatcher(paths.projectRoot, async (e) => {
|
|
2889
|
+
await ctx.activity.add(e);
|
|
2890
|
+
if (e.kind === "branch-switch") {
|
|
2891
|
+
try {
|
|
2892
|
+
const to = e.details?.to ?? "unknown";
|
|
2893
|
+
log.info(`branch switched to '${to}' \u2014 rebuilding graph\u2026`);
|
|
2894
|
+
await scanProject(paths.projectRoot, { silent: true });
|
|
2895
|
+
const [g, idx] = await Promise.all([
|
|
2896
|
+
readGraph(paths.infoGraph),
|
|
2897
|
+
readSymbolIndex(paths.symbolIndex)
|
|
2898
|
+
]);
|
|
2899
|
+
ctx.graph = g;
|
|
2900
|
+
ctx.symbolIndex = idx;
|
|
2901
|
+
log.info(`graph rebuilt for '${to}' (${g.symbol_count} symbols).`);
|
|
2902
|
+
} catch (err2) {
|
|
2903
|
+
log.warn(`branch rescan failed: ${err2.message}`);
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
});
|
|
2907
|
+
try {
|
|
2908
|
+
await fileWatcher.start();
|
|
2909
|
+
} catch (err2) {
|
|
2910
|
+
log.warn(`file watcher failed to start: ${err2.message}`);
|
|
2911
|
+
}
|
|
2912
|
+
try {
|
|
2913
|
+
await gitWatcher.start();
|
|
2914
|
+
} catch (err2) {
|
|
2915
|
+
log.warn(`git watcher failed to start: ${err2.message}`);
|
|
2916
|
+
}
|
|
2917
|
+
const url = `http://127.0.0.1:${port}`;
|
|
2918
|
+
return {
|
|
2919
|
+
port,
|
|
2920
|
+
url,
|
|
2921
|
+
async stop() {
|
|
2922
|
+
await fileWatcher.stop().catch(() => void 0);
|
|
2923
|
+
await gitWatcher.stop().catch(() => void 0);
|
|
2924
|
+
await new Promise((resolve2, reject) => {
|
|
2925
|
+
nodeServer.close((err2) => err2 ? reject(err2) : resolve2());
|
|
2926
|
+
});
|
|
2927
|
+
}
|
|
2928
|
+
};
|
|
2929
|
+
}
|
|
2930
|
+
export {
|
|
2931
|
+
startServer
|
|
2932
|
+
};
|
|
2933
|
+
//# sourceMappingURL=index.js.map
|