ctxo-mcp 0.3.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -37
- package/dist/{chunk-N6GPODUY.js → chunk-4OI75JDS.js} +1 -1
- package/dist/{chunk-N6GPODUY.js.map → chunk-4OI75JDS.js.map} +1 -1
- package/dist/{chunk-XSHNN6PU.js → chunk-WZKXGKKI.js} +152 -11
- package/dist/chunk-WZKXGKKI.js.map +1 -0
- package/dist/{cli-router-NRUGPICL.js → cli-router-H557TJAM.js} +167 -4
- package/dist/cli-router-H557TJAM.js.map +1 -0
- package/dist/index.js +846 -44
- package/dist/index.js.map +1 -1
- package/dist/{json-index-reader-FCKSKA6R.js → json-index-reader-Z6VHJ47M.js} +2 -2
- package/package.json +1 -1
- package/dist/chunk-XSHNN6PU.js.map +0 -1
- package/dist/cli-router-NRUGPICL.js.map +0 -1
- /package/dist/{json-index-reader-FCKSKA6R.js.map → json-index-reader-Z6VHJ47M.js.map} +0 -0
package/dist/index.js
CHANGED
|
@@ -1,20 +1,94 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
RevertDetector,
|
|
4
|
+
SessionRecorderAdapter,
|
|
4
5
|
SimpleGitAdapter,
|
|
5
6
|
SqliteStorageAdapter,
|
|
6
7
|
createLogger,
|
|
7
8
|
loadCoChangeMap
|
|
8
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-WZKXGKKI.js";
|
|
9
10
|
import {
|
|
10
11
|
DetailLevelSchema,
|
|
11
12
|
JsonIndexReader
|
|
12
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-4OI75JDS.js";
|
|
13
14
|
import "./chunk-JIDIH7DS.js";
|
|
14
15
|
|
|
15
16
|
// src/index.ts
|
|
16
17
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
17
18
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
19
|
+
|
|
20
|
+
// src/adapters/transport/http-server-transport.ts
|
|
21
|
+
import { createServer } from "http";
|
|
22
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
23
|
+
import { randomUUID } from "crypto";
|
|
24
|
+
var log = createLogger("ctxo:http");
|
|
25
|
+
async function startHttpTransport(serverFactory, port) {
|
|
26
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
27
|
+
const httpServer = createServer(async (req, res) => {
|
|
28
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
29
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
30
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Accept, Mcp-Session-Id");
|
|
31
|
+
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
|
|
32
|
+
if (req.method === "OPTIONS") {
|
|
33
|
+
res.writeHead(204);
|
|
34
|
+
res.end();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
38
|
+
if (sessionId && sessions.has(sessionId)) {
|
|
39
|
+
try {
|
|
40
|
+
await sessions.get(sessionId).transport.handleRequest(req, res);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
log.error("Request error: %s", err.message);
|
|
43
|
+
if (!res.headersSent) {
|
|
44
|
+
res.writeHead(500);
|
|
45
|
+
res.end();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (sessionId && !sessions.has(sessionId)) {
|
|
51
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
52
|
+
res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32001, message: "Session not found" }, id: null }));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const server = await serverFactory();
|
|
57
|
+
const transport = new StreamableHTTPServerTransport({
|
|
58
|
+
sessionIdGenerator: () => randomUUID()
|
|
59
|
+
});
|
|
60
|
+
transport.onclose = () => {
|
|
61
|
+
if (transport.sessionId) {
|
|
62
|
+
sessions.delete(transport.sessionId);
|
|
63
|
+
log.info("Session closed: %s", transport.sessionId);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
await server.connect(transport);
|
|
67
|
+
await transport.handleRequest(req, res);
|
|
68
|
+
if (transport.sessionId) {
|
|
69
|
+
sessions.set(transport.sessionId, { transport, server });
|
|
70
|
+
log.info("New session: %s (total: %d)", transport.sessionId, sessions.size);
|
|
71
|
+
}
|
|
72
|
+
} catch (err) {
|
|
73
|
+
log.error("Session creation error: %s", err.message);
|
|
74
|
+
if (!res.headersSent) {
|
|
75
|
+
res.writeHead(500);
|
|
76
|
+
res.end();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
httpServer.on("error", reject);
|
|
82
|
+
httpServer.listen(port, () => {
|
|
83
|
+
log.info("MCP HTTP server listening on http://localhost:%d", port);
|
|
84
|
+
process.stderr.write(`[ctxo] MCP HTTP server running at http://localhost:${port}
|
|
85
|
+
`);
|
|
86
|
+
resolve({ httpServer });
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/index.ts
|
|
18
92
|
import { z as z15 } from "zod";
|
|
19
93
|
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
20
94
|
import { join as join2 } from "path";
|
|
@@ -1342,6 +1416,9 @@ var ContextAssembler = class {
|
|
|
1342
1416
|
isInterfaceOrType(kind) {
|
|
1343
1417
|
return kind === "interface" || kind === "type";
|
|
1344
1418
|
}
|
|
1419
|
+
estimateTokensPublic(node) {
|
|
1420
|
+
return this.estimateTokens(node);
|
|
1421
|
+
}
|
|
1345
1422
|
estimateTokens(node) {
|
|
1346
1423
|
if (node.startOffset !== void 0 && node.endOffset !== void 0) {
|
|
1347
1424
|
return Math.ceil((node.endOffset - node.startOffset) / 4);
|
|
@@ -1400,18 +1477,575 @@ function handleGetContextForTask(storage, masking, staleness, ctxoRoot = ".ctxo"
|
|
|
1400
1477
|
|
|
1401
1478
|
// src/adapters/mcp/get-ranked-context.ts
|
|
1402
1479
|
import { z as z8 } from "zod";
|
|
1480
|
+
|
|
1481
|
+
// src/core/search/symbol-tokenizer.ts
|
|
1482
|
+
var STOP_WORDS = /* @__PURE__ */ new Set([
|
|
1483
|
+
"get",
|
|
1484
|
+
"set",
|
|
1485
|
+
"is",
|
|
1486
|
+
"has",
|
|
1487
|
+
"can",
|
|
1488
|
+
"do",
|
|
1489
|
+
"the",
|
|
1490
|
+
"a",
|
|
1491
|
+
"an",
|
|
1492
|
+
"of",
|
|
1493
|
+
"to",
|
|
1494
|
+
"in",
|
|
1495
|
+
"for",
|
|
1496
|
+
"on",
|
|
1497
|
+
"with",
|
|
1498
|
+
"at",
|
|
1499
|
+
"by",
|
|
1500
|
+
"from",
|
|
1501
|
+
"as",
|
|
1502
|
+
"into",
|
|
1503
|
+
"not",
|
|
1504
|
+
"no",
|
|
1505
|
+
"or"
|
|
1506
|
+
]);
|
|
1507
|
+
var DEFAULT_OPTIONS = {
|
|
1508
|
+
includeStopWords: false,
|
|
1509
|
+
includeOriginal: true,
|
|
1510
|
+
includeFilePath: false
|
|
1511
|
+
};
|
|
1512
|
+
var SymbolTokenizer = class _SymbolTokenizer {
|
|
1513
|
+
options;
|
|
1514
|
+
constructor(options) {
|
|
1515
|
+
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
1516
|
+
}
|
|
1517
|
+
/**
|
|
1518
|
+
* Tokenize a symbol name into searchable tokens.
|
|
1519
|
+
*
|
|
1520
|
+
* Examples:
|
|
1521
|
+
* "getCoChangeMetrics" → ["get", "co", "change", "metrics"] (or without stop words: ["co", "change", "metrics"])
|
|
1522
|
+
* "SqliteStorageAdapter" → ["sqlite", "storage", "adapter"]
|
|
1523
|
+
* "i_storage_port" → ["i", "storage", "port"]
|
|
1524
|
+
* "BM25Scorer" → ["bm", "25", "scorer"]
|
|
1525
|
+
* "DB_PASSWORD" → ["db", "password"]
|
|
1526
|
+
*/
|
|
1527
|
+
tokenize(symbolName, filePath) {
|
|
1528
|
+
const splitTokens = _SymbolTokenizer.splitName(symbolName);
|
|
1529
|
+
const lowered = splitTokens.map((t) => t.toLowerCase());
|
|
1530
|
+
const tokens = this.options.includeStopWords ? lowered : lowered.filter((t) => !STOP_WORDS.has(t));
|
|
1531
|
+
if (this.options.includeOriginal) {
|
|
1532
|
+
const original = symbolName.toLowerCase();
|
|
1533
|
+
if (!tokens.includes(original)) {
|
|
1534
|
+
tokens.unshift(original);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
if (this.options.includeFilePath && filePath) {
|
|
1538
|
+
const pathTokens = _SymbolTokenizer.tokenizeFilePath(filePath);
|
|
1539
|
+
for (const pt of pathTokens) {
|
|
1540
|
+
if (!tokens.includes(pt)) {
|
|
1541
|
+
tokens.push(pt);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
return tokens;
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Tokenize a search query. Queries use stop words and don't include originals.
|
|
1549
|
+
*/
|
|
1550
|
+
tokenizeQuery(query) {
|
|
1551
|
+
const words = query.trim().split(/\s+/);
|
|
1552
|
+
const tokens = [];
|
|
1553
|
+
for (const word of words) {
|
|
1554
|
+
const split = _SymbolTokenizer.splitName(word);
|
|
1555
|
+
for (const t of split) {
|
|
1556
|
+
const lowered = t.toLowerCase();
|
|
1557
|
+
if (lowered.length > 0 && !tokens.includes(lowered)) {
|
|
1558
|
+
tokens.push(lowered);
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
return tokens;
|
|
1563
|
+
}
|
|
1564
|
+
/**
|
|
1565
|
+
* Split a name into constituent words based on naming convention boundaries.
|
|
1566
|
+
*
|
|
1567
|
+
* Rules:
|
|
1568
|
+
* 1. Split on underscores and hyphens (snake_case, kebab-case)
|
|
1569
|
+
* 2. Split on camelCase/PascalCase boundaries (uppercase after lowercase)
|
|
1570
|
+
* 3. Split acronym boundaries (e.g., "HTMLParser" → ["HTML", "Parser"])
|
|
1571
|
+
* 4. Split on digit ↔ letter boundaries
|
|
1572
|
+
*/
|
|
1573
|
+
static splitName(name) {
|
|
1574
|
+
if (!name || name.length === 0) return [];
|
|
1575
|
+
const segments = name.split(/[_\-./\\]+/).filter((s) => s.length > 0);
|
|
1576
|
+
const tokens = [];
|
|
1577
|
+
for (const segment of segments) {
|
|
1578
|
+
tokens.push(..._SymbolTokenizer.splitCamelCase(segment));
|
|
1579
|
+
}
|
|
1580
|
+
return tokens.filter((t) => t.length > 0);
|
|
1581
|
+
}
|
|
1582
|
+
/**
|
|
1583
|
+
* Split a single segment on camelCase/PascalCase/digit boundaries.
|
|
1584
|
+
*/
|
|
1585
|
+
static splitCamelCase(segment) {
|
|
1586
|
+
const tokens = [];
|
|
1587
|
+
let current = "";
|
|
1588
|
+
for (let i = 0; i < segment.length; i++) {
|
|
1589
|
+
const ch = segment[i];
|
|
1590
|
+
const prev = i > 0 ? segment[i - 1] : "";
|
|
1591
|
+
if (current.length > 0) {
|
|
1592
|
+
const prevIsDigit = isDigit(prev);
|
|
1593
|
+
const currIsDigit = isDigit(ch);
|
|
1594
|
+
if (prevIsDigit !== currIsDigit) {
|
|
1595
|
+
tokens.push(current);
|
|
1596
|
+
current = ch;
|
|
1597
|
+
continue;
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
if (current.length > 0 && isLower(prev) && isUpper(ch)) {
|
|
1601
|
+
tokens.push(current);
|
|
1602
|
+
current = ch;
|
|
1603
|
+
continue;
|
|
1604
|
+
}
|
|
1605
|
+
if (current.length > 1 && isUpper(ch) === false && isUpper(prev) && !isDigit(ch)) {
|
|
1606
|
+
const lastChar = current[current.length - 1];
|
|
1607
|
+
if (isUpper(lastChar)) {
|
|
1608
|
+
tokens.push(current.slice(0, -1));
|
|
1609
|
+
current = lastChar + ch;
|
|
1610
|
+
continue;
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
current += ch;
|
|
1614
|
+
}
|
|
1615
|
+
if (current.length > 0) {
|
|
1616
|
+
tokens.push(current);
|
|
1617
|
+
}
|
|
1618
|
+
return tokens;
|
|
1619
|
+
}
|
|
1620
|
+
/**
|
|
1621
|
+
* Extract searchable tokens from a file path.
|
|
1622
|
+
* "src/core/search/symbol-tokenizer.ts" → ["src", "core", "search", "symbol", "tokenizer", "ts"]
|
|
1623
|
+
*/
|
|
1624
|
+
static tokenizeFilePath(filePath) {
|
|
1625
|
+
const withoutExt = filePath.replace(/\.[^.]+$/, "");
|
|
1626
|
+
const segments = withoutExt.split(/[/\\]+/);
|
|
1627
|
+
const tokens = [];
|
|
1628
|
+
for (const seg of segments) {
|
|
1629
|
+
const parts = seg.split(/[_\-.]+/).filter((s) => s.length > 0);
|
|
1630
|
+
for (const p of parts) {
|
|
1631
|
+
const lowered = p.toLowerCase();
|
|
1632
|
+
if (lowered.length > 0 && !tokens.includes(lowered)) {
|
|
1633
|
+
tokens.push(lowered);
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
return tokens;
|
|
1638
|
+
}
|
|
1639
|
+
/** Check if a token is a stop word */
|
|
1640
|
+
static isStopWord(token) {
|
|
1641
|
+
return STOP_WORDS.has(token.toLowerCase());
|
|
1642
|
+
}
|
|
1643
|
+
};
|
|
1644
|
+
function isUpper(ch) {
|
|
1645
|
+
return ch >= "A" && ch <= "Z";
|
|
1646
|
+
}
|
|
1647
|
+
function isLower(ch) {
|
|
1648
|
+
return ch >= "a" && ch <= "z";
|
|
1649
|
+
}
|
|
1650
|
+
function isDigit(ch) {
|
|
1651
|
+
return ch >= "0" && ch <= "9";
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
// src/core/search/fuzzy-corrector.ts
|
|
1655
|
+
var FuzzyCorrector = class {
|
|
1656
|
+
/** term → frequency (for tie-breaking) */
|
|
1657
|
+
vocabulary = /* @__PURE__ */ new Map();
|
|
1658
|
+
buildVocabulary(termFrequencies) {
|
|
1659
|
+
this.vocabulary = new Map(termFrequencies);
|
|
1660
|
+
}
|
|
1661
|
+
/**
|
|
1662
|
+
* Attempt to correct query tokens.
|
|
1663
|
+
* Returns null if no correction found or no improvement possible.
|
|
1664
|
+
*/
|
|
1665
|
+
correct(queryTokens) {
|
|
1666
|
+
if (this.vocabulary.size === 0) return null;
|
|
1667
|
+
const corrections = [];
|
|
1668
|
+
const correctedTokens = [];
|
|
1669
|
+
let anyChanged = false;
|
|
1670
|
+
for (const token of queryTokens) {
|
|
1671
|
+
if (token.length <= 2) {
|
|
1672
|
+
correctedTokens.push(token);
|
|
1673
|
+
continue;
|
|
1674
|
+
}
|
|
1675
|
+
if (this.vocabulary.has(token)) {
|
|
1676
|
+
correctedTokens.push(token);
|
|
1677
|
+
continue;
|
|
1678
|
+
}
|
|
1679
|
+
const maxDist = token.length <= 5 ? 1 : 2;
|
|
1680
|
+
const best = this.findClosest(token, maxDist);
|
|
1681
|
+
if (best) {
|
|
1682
|
+
corrections.push({
|
|
1683
|
+
original: token,
|
|
1684
|
+
corrected: best.term,
|
|
1685
|
+
distance: best.distance
|
|
1686
|
+
});
|
|
1687
|
+
correctedTokens.push(best.term);
|
|
1688
|
+
anyChanged = true;
|
|
1689
|
+
} else {
|
|
1690
|
+
correctedTokens.push(token);
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
if (!anyChanged) return null;
|
|
1694
|
+
return {
|
|
1695
|
+
originalQuery: queryTokens.join(" "),
|
|
1696
|
+
correctedQuery: correctedTokens.join(" "),
|
|
1697
|
+
corrections
|
|
1698
|
+
};
|
|
1699
|
+
}
|
|
1700
|
+
/**
|
|
1701
|
+
* Find the closest vocabulary term within maxDistance.
|
|
1702
|
+
* Tie-breaks by frequency (higher = preferred).
|
|
1703
|
+
*/
|
|
1704
|
+
findClosest(token, maxDistance) {
|
|
1705
|
+
let bestTerm = null;
|
|
1706
|
+
let bestDist = maxDistance + 1;
|
|
1707
|
+
let bestFreq = -1;
|
|
1708
|
+
for (const [vocabTerm, freq] of this.vocabulary) {
|
|
1709
|
+
if (Math.abs(vocabTerm.length - token.length) > maxDistance) continue;
|
|
1710
|
+
const dist = damerauLevenshtein(token, vocabTerm);
|
|
1711
|
+
if (dist <= maxDistance) {
|
|
1712
|
+
if (dist < bestDist || dist === bestDist && freq > bestFreq) {
|
|
1713
|
+
bestTerm = vocabTerm;
|
|
1714
|
+
bestDist = dist;
|
|
1715
|
+
bestFreq = freq;
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
return bestTerm !== null ? { term: bestTerm, distance: bestDist } : null;
|
|
1720
|
+
}
|
|
1721
|
+
};
|
|
1722
|
+
function damerauLevenshtein(a, b) {
|
|
1723
|
+
const lenA = a.length;
|
|
1724
|
+
const lenB = b.length;
|
|
1725
|
+
if (lenA === 0) return lenB;
|
|
1726
|
+
if (lenB === 0) return lenA;
|
|
1727
|
+
const d = Array.from({ length: lenA + 1 }, () => new Array(lenB + 1).fill(0));
|
|
1728
|
+
for (let i = 0; i <= lenA; i++) d[i][0] = i;
|
|
1729
|
+
for (let j = 0; j <= lenB; j++) d[0][j] = j;
|
|
1730
|
+
for (let i = 1; i <= lenA; i++) {
|
|
1731
|
+
for (let j = 1; j <= lenB; j++) {
|
|
1732
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
1733
|
+
d[i][j] = Math.min(
|
|
1734
|
+
d[i - 1][j] + 1,
|
|
1735
|
+
// deletion
|
|
1736
|
+
d[i][j - 1] + 1,
|
|
1737
|
+
// insertion
|
|
1738
|
+
d[i - 1][j - 1] + cost
|
|
1739
|
+
// substitution
|
|
1740
|
+
);
|
|
1741
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
|
1742
|
+
d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + cost);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
return d[lenA][lenB];
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
// src/core/search/search-engine.ts
|
|
1750
|
+
var log2 = createLogger("ctxo:search");
|
|
1751
|
+
var DEFAULT_CONFIG = {
|
|
1752
|
+
bm25: { k1: 1.2, b: 0.25 },
|
|
1753
|
+
pageRankWeight: 0.5,
|
|
1754
|
+
trigramPenalty: 0.8,
|
|
1755
|
+
phase2Threshold: 3,
|
|
1756
|
+
fuzzyThreshold: 3,
|
|
1757
|
+
maxPerPhase: 50
|
|
1758
|
+
};
|
|
1759
|
+
var SearchEngine = class {
|
|
1760
|
+
config;
|
|
1761
|
+
tokenizer;
|
|
1762
|
+
fuzzy;
|
|
1763
|
+
// Primary index (exact + tokenized)
|
|
1764
|
+
documents = [];
|
|
1765
|
+
primaryIndex = /* @__PURE__ */ new Map();
|
|
1766
|
+
avgDocLength = 0;
|
|
1767
|
+
// Trigram index (character 3-grams)
|
|
1768
|
+
trigramIndex = /* @__PURE__ */ new Map();
|
|
1769
|
+
// PageRank scores
|
|
1770
|
+
pageRankScores = /* @__PURE__ */ new Map();
|
|
1771
|
+
constructor(config) {
|
|
1772
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
1773
|
+
this.tokenizer = new SymbolTokenizer({ includeOriginal: true, includeFilePath: true });
|
|
1774
|
+
this.fuzzy = new FuzzyCorrector();
|
|
1775
|
+
}
|
|
1776
|
+
getTier() {
|
|
1777
|
+
return "in-memory";
|
|
1778
|
+
}
|
|
1779
|
+
buildIndex(symbols, pageRankScores) {
|
|
1780
|
+
const start = performance.now();
|
|
1781
|
+
this.documents = [];
|
|
1782
|
+
this.primaryIndex = /* @__PURE__ */ new Map();
|
|
1783
|
+
this.trigramIndex = /* @__PURE__ */ new Map();
|
|
1784
|
+
this.pageRankScores = pageRankScores ?? /* @__PURE__ */ new Map();
|
|
1785
|
+
let totalLength = 0;
|
|
1786
|
+
const vocabFreq = /* @__PURE__ */ new Map();
|
|
1787
|
+
for (const sym of symbols) {
|
|
1788
|
+
const filePath = sym.symbolId.split("::")[0] ?? "";
|
|
1789
|
+
const tokens = this.tokenizer.tokenize(sym.name, filePath);
|
|
1790
|
+
const doc = {
|
|
1791
|
+
symbolId: sym.symbolId,
|
|
1792
|
+
name: sym.name,
|
|
1793
|
+
kind: sym.kind,
|
|
1794
|
+
filePath,
|
|
1795
|
+
tokens,
|
|
1796
|
+
length: tokens.length
|
|
1797
|
+
};
|
|
1798
|
+
const docIndex = this.documents.length;
|
|
1799
|
+
this.documents.push(doc);
|
|
1800
|
+
totalLength += tokens.length;
|
|
1801
|
+
const termFreqs = /* @__PURE__ */ new Map();
|
|
1802
|
+
for (const token of tokens) {
|
|
1803
|
+
termFreqs.set(token, (termFreqs.get(token) ?? 0) + 1);
|
|
1804
|
+
vocabFreq.set(token, (vocabFreq.get(token) ?? 0) + 1);
|
|
1805
|
+
}
|
|
1806
|
+
for (const [term, tf] of termFreqs) {
|
|
1807
|
+
let postings = this.primaryIndex.get(term);
|
|
1808
|
+
if (!postings) {
|
|
1809
|
+
postings = [];
|
|
1810
|
+
this.primaryIndex.set(term, postings);
|
|
1811
|
+
}
|
|
1812
|
+
postings.push({ docIndex, termFrequency: tf });
|
|
1813
|
+
}
|
|
1814
|
+
const nameLower = sym.name.toLowerCase();
|
|
1815
|
+
const trigrams = this.extractTrigrams(nameLower);
|
|
1816
|
+
const trigramFreqs = /* @__PURE__ */ new Map();
|
|
1817
|
+
for (const tri of trigrams) {
|
|
1818
|
+
trigramFreqs.set(tri, (trigramFreqs.get(tri) ?? 0) + 1);
|
|
1819
|
+
}
|
|
1820
|
+
for (const [tri, tf] of trigramFreqs) {
|
|
1821
|
+
let postings = this.trigramIndex.get(tri);
|
|
1822
|
+
if (!postings) {
|
|
1823
|
+
postings = [];
|
|
1824
|
+
this.trigramIndex.set(tri, postings);
|
|
1825
|
+
}
|
|
1826
|
+
postings.push({ docIndex, termFrequency: tf });
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
this.avgDocLength = this.documents.length > 0 ? totalLength / this.documents.length : 1;
|
|
1830
|
+
this.fuzzy.buildVocabulary(vocabFreq);
|
|
1831
|
+
const elapsed = performance.now() - start;
|
|
1832
|
+
log2.info(`Index built: ${symbols.length} symbols, ${this.primaryIndex.size} terms, ${this.trigramIndex.size} trigrams (${elapsed.toFixed(0)}ms)`);
|
|
1833
|
+
}
|
|
1834
|
+
search(query, limit = 50) {
|
|
1835
|
+
const start = performance.now();
|
|
1836
|
+
const queryTokens = this.tokenizer.tokenizeQuery(query);
|
|
1837
|
+
if (queryTokens.length === 0) {
|
|
1838
|
+
return this.emptyResponse(query, start);
|
|
1839
|
+
}
|
|
1840
|
+
const metrics = {
|
|
1841
|
+
porterHits: 0,
|
|
1842
|
+
trigramHits: 0,
|
|
1843
|
+
phase2Activated: false,
|
|
1844
|
+
fuzzyApplied: false,
|
|
1845
|
+
latencyMs: 0
|
|
1846
|
+
};
|
|
1847
|
+
const phase1Scores = this.bm25Search(queryTokens, this.primaryIndex);
|
|
1848
|
+
metrics.porterHits = phase1Scores.size;
|
|
1849
|
+
let allScores = phase1Scores;
|
|
1850
|
+
if (phase1Scores.size < this.config.phase2Threshold) {
|
|
1851
|
+
metrics.phase2Activated = true;
|
|
1852
|
+
const queryLower = query.toLowerCase();
|
|
1853
|
+
if (queryLower.length >= 3) {
|
|
1854
|
+
const queryTrigrams = this.extractTrigrams(queryLower);
|
|
1855
|
+
const phase2Scores = this.bm25Search(queryTrigrams, this.trigramIndex);
|
|
1856
|
+
metrics.trigramHits = phase2Scores.size;
|
|
1857
|
+
allScores = this.mergeScores(phase1Scores, phase2Scores, this.config.trigramPenalty);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
let fuzzyCorrection;
|
|
1861
|
+
if (allScores.size < this.config.fuzzyThreshold) {
|
|
1862
|
+
const correction = this.fuzzy.correct(queryTokens);
|
|
1863
|
+
if (correction) {
|
|
1864
|
+
metrics.fuzzyApplied = true;
|
|
1865
|
+
fuzzyCorrection = correction;
|
|
1866
|
+
const correctedTokens = this.tokenizer.tokenizeQuery(correction.correctedQuery);
|
|
1867
|
+
const fuzzyScores = this.bm25Search(correctedTokens, this.primaryIndex);
|
|
1868
|
+
allScores = this.mergeScores(allScores, fuzzyScores, 0.9);
|
|
1869
|
+
if (allScores.size < this.config.fuzzyThreshold && correction.correctedQuery.length >= 3) {
|
|
1870
|
+
const correctedTrigrams = this.extractTrigrams(correction.correctedQuery.toLowerCase());
|
|
1871
|
+
const fuzzyTrigramScores = this.bm25Search(correctedTrigrams, this.trigramIndex);
|
|
1872
|
+
allScores = this.mergeScores(allScores, fuzzyTrigramScores, this.config.trigramPenalty * 0.9);
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
const rawRelevance = new Map(allScores);
|
|
1877
|
+
const effectiveQueryTerms = fuzzyCorrection ? this.tokenizer.tokenizeQuery(fuzzyCorrection.correctedQuery) : queryTokens;
|
|
1878
|
+
if (effectiveQueryTerms.length >= 2) {
|
|
1879
|
+
for (const [docIdx, score] of allScores) {
|
|
1880
|
+
const doc = this.documents[docIdx];
|
|
1881
|
+
const boost = this.bigramBoost(effectiveQueryTerms, doc.tokens);
|
|
1882
|
+
allScores.set(docIdx, score * boost);
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
for (const [docIdx, score] of allScores) {
|
|
1886
|
+
const doc = this.documents[docIdx];
|
|
1887
|
+
const pr = this.pageRankScores.get(doc.symbolId) ?? 0;
|
|
1888
|
+
allScores.set(docIdx, score * (1 + this.config.pageRankWeight * pr));
|
|
1889
|
+
}
|
|
1890
|
+
metrics.latencyMs = performance.now() - start;
|
|
1891
|
+
const results = this.buildResults(allScores, rawRelevance, limit);
|
|
1892
|
+
const phaseInfo = metrics.phase2Activated ? "phase2=yes" : "phase2=no";
|
|
1893
|
+
if (fuzzyCorrection) {
|
|
1894
|
+
log2.info(`search "${query}" \u2192 fuzzy "${fuzzyCorrection.correctedQuery}": ${results.length} results (${metrics.latencyMs.toFixed(0)}ms)`);
|
|
1895
|
+
} else {
|
|
1896
|
+
log2.info(`search "${query}": ${results.length} results, porter=${metrics.porterHits} trigram=${metrics.trigramHits} ${phaseInfo} (${metrics.latencyMs.toFixed(0)}ms)`);
|
|
1897
|
+
}
|
|
1898
|
+
return { query, results, metrics, ...fuzzyCorrection ? { fuzzyCorrection } : {} };
|
|
1899
|
+
}
|
|
1900
|
+
updateFile(filePath, symbols) {
|
|
1901
|
+
const oldIndices = /* @__PURE__ */ new Set();
|
|
1902
|
+
for (let i = 0; i < this.documents.length; i++) {
|
|
1903
|
+
if (this.documents[i].filePath === filePath) {
|
|
1904
|
+
oldIndices.add(i);
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
if (oldIndices.size === 0 && symbols.length === 0) return;
|
|
1908
|
+
const allSymbols = [];
|
|
1909
|
+
for (let i = 0; i < this.documents.length; i++) {
|
|
1910
|
+
if (!oldIndices.has(i)) {
|
|
1911
|
+
const doc = this.documents[i];
|
|
1912
|
+
allSymbols.push({
|
|
1913
|
+
symbolId: doc.symbolId,
|
|
1914
|
+
name: doc.name,
|
|
1915
|
+
kind: doc.kind,
|
|
1916
|
+
startLine: 0,
|
|
1917
|
+
endLine: 0
|
|
1918
|
+
});
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
allSymbols.push(...symbols);
|
|
1922
|
+
this.buildIndex(allSymbols, this.pageRankScores);
|
|
1923
|
+
}
|
|
1924
|
+
/**
|
|
1925
|
+
* BM25 scoring over an inverted index.
|
|
1926
|
+
* Returns Map<docIndex, score>
|
|
1927
|
+
*/
|
|
1928
|
+
bm25Search(queryTerms, index) {
|
|
1929
|
+
const scores = /* @__PURE__ */ new Map();
|
|
1930
|
+
const N = this.documents.length;
|
|
1931
|
+
const { k1, b } = this.config.bm25;
|
|
1932
|
+
for (const term of queryTerms) {
|
|
1933
|
+
const postings = index.get(term);
|
|
1934
|
+
if (!postings) continue;
|
|
1935
|
+
const df = postings.length;
|
|
1936
|
+
const idf = Math.log((N - df + 0.5) / (df + 0.5) + 1);
|
|
1937
|
+
for (const { docIndex, termFrequency } of postings) {
|
|
1938
|
+
const doc = this.documents[docIndex];
|
|
1939
|
+
const tf = termFrequency;
|
|
1940
|
+
const docLen = doc.length;
|
|
1941
|
+
const numerator = tf * (k1 + 1);
|
|
1942
|
+
const denominator = tf + k1 * (1 - b + b * (docLen / this.avgDocLength));
|
|
1943
|
+
const termScore = idf * (numerator / denominator);
|
|
1944
|
+
scores.set(docIndex, (scores.get(docIndex) ?? 0) + termScore);
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
const queryJoined = queryTerms.join("");
|
|
1948
|
+
for (const [docIdx, score] of scores) {
|
|
1949
|
+
const doc = this.documents[docIdx];
|
|
1950
|
+
const nameLower = doc.name.toLowerCase();
|
|
1951
|
+
if (nameLower === queryJoined || nameLower === queryTerms.join(" ")) {
|
|
1952
|
+
scores.set(docIdx, score * 5);
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
return scores;
|
|
1956
|
+
}
|
|
1957
|
+
/**
|
|
1958
|
+
* Merge two score maps. Phase 2 scores are penalized.
|
|
1959
|
+
*/
|
|
1960
|
+
mergeScores(primary, secondary, penalty) {
|
|
1961
|
+
const merged = new Map(primary);
|
|
1962
|
+
for (const [docIdx, score] of secondary) {
|
|
1963
|
+
const existing = merged.get(docIdx);
|
|
1964
|
+
const penalized = score * penalty;
|
|
1965
|
+
if (existing !== void 0) {
|
|
1966
|
+
merged.set(docIdx, Math.max(existing, penalized));
|
|
1967
|
+
} else {
|
|
1968
|
+
merged.set(docIdx, penalized);
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
return merged;
|
|
1972
|
+
}
|
|
1973
|
+
/**
|
|
1974
|
+
* Bigram boost for multi-word queries.
|
|
1975
|
+
* Each adjacent query term pair found adjacent in symbol tokens → 2x boost.
|
|
1976
|
+
*/
|
|
1977
|
+
bigramBoost(queryTerms, symbolTokens) {
|
|
1978
|
+
if (queryTerms.length < 2) return 1;
|
|
1979
|
+
let adjacentPairs = 0;
|
|
1980
|
+
for (let i = 0; i < queryTerms.length - 1; i++) {
|
|
1981
|
+
const idxA = symbolTokens.findIndex((t) => t.includes(queryTerms[i]));
|
|
1982
|
+
const idxB = symbolTokens.findIndex((t) => t.includes(queryTerms[i + 1]));
|
|
1983
|
+
if (idxA >= 0 && idxB >= 0 && Math.abs(idxA - idxB) === 1) {
|
|
1984
|
+
adjacentPairs++;
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
return 1 + adjacentPairs * 2;
|
|
1988
|
+
}
|
|
1989
|
+
/**
|
|
1990
|
+
* Extract character trigrams from a string.
|
|
1991
|
+
* "sqlite" → ["sql", "qli", "lit", "ite"]
|
|
1992
|
+
*/
|
|
1993
|
+
extractTrigrams(text) {
|
|
1994
|
+
const trigrams = [];
|
|
1995
|
+
for (let i = 0; i <= text.length - 3; i++) {
|
|
1996
|
+
trigrams.push(text.substring(i, i + 3));
|
|
1997
|
+
}
|
|
1998
|
+
return trigrams;
|
|
1999
|
+
}
|
|
2000
|
+
buildResults(combinedScores, rawRelevance, limit) {
|
|
2001
|
+
const entries = [...combinedScores.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
|
|
2002
|
+
return entries.map(([docIdx, combined]) => {
|
|
2003
|
+
const doc = this.documents[docIdx];
|
|
2004
|
+
const pr = this.pageRankScores.get(doc.symbolId) ?? 0;
|
|
2005
|
+
return {
|
|
2006
|
+
symbolId: doc.symbolId,
|
|
2007
|
+
name: doc.name,
|
|
2008
|
+
kind: doc.kind,
|
|
2009
|
+
filePath: doc.filePath,
|
|
2010
|
+
relevanceScore: rawRelevance.get(docIdx) ?? 0,
|
|
2011
|
+
importanceScore: pr,
|
|
2012
|
+
combinedScore: combined
|
|
2013
|
+
};
|
|
2014
|
+
});
|
|
2015
|
+
}
|
|
2016
|
+
emptyResponse(query, start) {
|
|
2017
|
+
return {
|
|
2018
|
+
query,
|
|
2019
|
+
results: [],
|
|
2020
|
+
metrics: {
|
|
2021
|
+
porterHits: 0,
|
|
2022
|
+
trigramHits: 0,
|
|
2023
|
+
phase2Activated: false,
|
|
2024
|
+
fuzzyApplied: false,
|
|
2025
|
+
latencyMs: performance.now() - start
|
|
2026
|
+
}
|
|
2027
|
+
};
|
|
2028
|
+
}
|
|
2029
|
+
};
|
|
2030
|
+
|
|
2031
|
+
// src/adapters/mcp/get-ranked-context.ts
|
|
2032
|
+
var log3 = createLogger("ctxo:search");
|
|
1403
2033
|
var InputSchema8 = z8.object({
|
|
1404
2034
|
query: z8.string().min(1),
|
|
1405
2035
|
tokenBudget: z8.number().min(100).optional().default(4e3),
|
|
1406
|
-
strategy: z8.enum(["combined", "dependency", "importance"]).optional().default("combined")
|
|
2036
|
+
strategy: z8.enum(["combined", "dependency", "importance"]).optional().default("combined"),
|
|
2037
|
+
fuzzy: z8.boolean().optional().default(true),
|
|
2038
|
+
searchMode: z8.enum(["fts", "legacy"]).optional().default("fts")
|
|
1407
2039
|
});
|
|
1408
|
-
function handleGetRankedContext(storage, masking, staleness, ctxoRoot = ".ctxo") {
|
|
2040
|
+
function handleGetRankedContext(storage, masking, staleness, ctxoRoot = ".ctxo", searchEngine) {
|
|
1409
2041
|
const assembler = new ContextAssembler();
|
|
2042
|
+
const engine = searchEngine ?? new SearchEngine();
|
|
1410
2043
|
const getGraph = () => {
|
|
1411
2044
|
const jsonGraph = buildGraphFromJsonIndex(ctxoRoot);
|
|
1412
2045
|
if (jsonGraph.nodeCount > 0) return jsonGraph;
|
|
1413
2046
|
return buildGraphFromStorage(storage);
|
|
1414
2047
|
};
|
|
2048
|
+
let lastNodeCount = -1;
|
|
1415
2049
|
return (args) => {
|
|
1416
2050
|
try {
|
|
1417
2051
|
const parsed = InputSchema8.safeParse(args);
|
|
@@ -1420,8 +2054,62 @@ function handleGetRankedContext(storage, masking, staleness, ctxoRoot = ".ctxo")
|
|
|
1420
2054
|
content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
|
|
1421
2055
|
};
|
|
1422
2056
|
}
|
|
1423
|
-
const { query, tokenBudget, strategy } = parsed.data;
|
|
2057
|
+
const { query, tokenBudget, strategy, searchMode } = parsed.data;
|
|
1424
2058
|
const graph = getGraph();
|
|
2059
|
+
if (searchMode === "fts" && strategy !== "importance") {
|
|
2060
|
+
const currentNodeCount = graph.nodeCount;
|
|
2061
|
+
if (currentNodeCount !== lastNodeCount) {
|
|
2062
|
+
const allNodes = graph.allNodes();
|
|
2063
|
+
const pageRankScores = /* @__PURE__ */ new Map();
|
|
2064
|
+
let maxReverseEdges = 1;
|
|
2065
|
+
for (const node of allNodes) {
|
|
2066
|
+
const count = graph.getReverseEdges(node.symbolId).length;
|
|
2067
|
+
if (count > maxReverseEdges) maxReverseEdges = count;
|
|
2068
|
+
}
|
|
2069
|
+
for (const node of allNodes) {
|
|
2070
|
+
const count = graph.getReverseEdges(node.symbolId).length;
|
|
2071
|
+
pageRankScores.set(node.symbolId, count / maxReverseEdges);
|
|
2072
|
+
}
|
|
2073
|
+
engine.buildIndex(allNodes, pageRankScores);
|
|
2074
|
+
lastNodeCount = currentNodeCount;
|
|
2075
|
+
log3.info(`Search engine: ${engine.getTier()} (${allNodes.length} symbols)`);
|
|
2076
|
+
}
|
|
2077
|
+
const searchResult = engine.search(query, 100);
|
|
2078
|
+
const results = [];
|
|
2079
|
+
let totalTokens = 0;
|
|
2080
|
+
for (const sr of searchResult.results) {
|
|
2081
|
+
const node = graph.getNode(sr.symbolId);
|
|
2082
|
+
const tokens = node ? assembler.estimateTokensPublic(node) : 90;
|
|
2083
|
+
if (totalTokens + tokens > tokenBudget) continue;
|
|
2084
|
+
results.push({
|
|
2085
|
+
symbolId: sr.symbolId,
|
|
2086
|
+
name: sr.name,
|
|
2087
|
+
kind: sr.kind,
|
|
2088
|
+
file: sr.filePath,
|
|
2089
|
+
relevanceScore: Math.round(sr.relevanceScore * 1e3) / 1e3,
|
|
2090
|
+
importanceScore: Math.round(sr.importanceScore * 1e3) / 1e3,
|
|
2091
|
+
combinedScore: Math.round(sr.combinedScore * 1e3) / 1e3,
|
|
2092
|
+
tokens
|
|
2093
|
+
});
|
|
2094
|
+
totalTokens += tokens;
|
|
2095
|
+
}
|
|
2096
|
+
const payload2 = masking.mask(JSON.stringify(wrapResponse({
|
|
2097
|
+
query,
|
|
2098
|
+
strategy,
|
|
2099
|
+
results,
|
|
2100
|
+
totalTokens,
|
|
2101
|
+
tokenBudget,
|
|
2102
|
+
searchMetrics: searchResult.metrics,
|
|
2103
|
+
...searchResult.fuzzyCorrection ? { fuzzyCorrection: searchResult.fuzzyCorrection } : {}
|
|
2104
|
+
})));
|
|
2105
|
+
const content2 = [];
|
|
2106
|
+
if (staleness) {
|
|
2107
|
+
const warning = staleness.check(storage.listIndexedFiles());
|
|
2108
|
+
if (warning) content2.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
|
|
2109
|
+
}
|
|
2110
|
+
content2.push({ type: "text", text: payload2 });
|
|
2111
|
+
return { content: content2 };
|
|
2112
|
+
}
|
|
1425
2113
|
const result = assembler.assembleRanked(graph, query, strategy, tokenBudget);
|
|
1426
2114
|
const payload = masking.mask(JSON.stringify(wrapResponse(result)));
|
|
1427
2115
|
const content = [];
|
|
@@ -1445,9 +2133,12 @@ var InputSchema9 = z9.object({
|
|
|
1445
2133
|
pattern: z9.string().min(1),
|
|
1446
2134
|
kind: z9.enum(["function", "class", "interface", "method", "variable", "type"]).optional(),
|
|
1447
2135
|
filePattern: z9.string().optional(),
|
|
1448
|
-
limit: z9.number().int().min(1).max(100).optional().default(25)
|
|
2136
|
+
limit: z9.number().int().min(1).max(100).optional().default(25),
|
|
2137
|
+
mode: z9.enum(["regex", "fts"]).optional().default("regex")
|
|
1449
2138
|
});
|
|
1450
|
-
function handleSearchSymbols(storage, masking, staleness, ctxoRoot = ".ctxo") {
|
|
2139
|
+
function handleSearchSymbols(storage, masking, staleness, ctxoRoot = ".ctxo", searchEngine) {
|
|
2140
|
+
const engine = searchEngine ?? new SearchEngine();
|
|
2141
|
+
let lastFtsNodeCount = -1;
|
|
1451
2142
|
const getGraph = () => {
|
|
1452
2143
|
const jsonGraph = buildGraphFromJsonIndex(ctxoRoot);
|
|
1453
2144
|
if (jsonGraph.nodeCount > 0) return jsonGraph;
|
|
@@ -1461,9 +2152,49 @@ function handleSearchSymbols(storage, masking, staleness, ctxoRoot = ".ctxo") {
|
|
|
1461
2152
|
content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
|
|
1462
2153
|
};
|
|
1463
2154
|
}
|
|
1464
|
-
const { pattern, kind, filePattern, limit } = parsed.data;
|
|
2155
|
+
const { pattern, kind, filePattern, limit, mode } = parsed.data;
|
|
1465
2156
|
const graph = getGraph();
|
|
1466
2157
|
const allNodes = graph.allNodes();
|
|
2158
|
+
if (mode === "fts") {
|
|
2159
|
+
if (allNodes.length !== lastFtsNodeCount) {
|
|
2160
|
+
engine.buildIndex(allNodes);
|
|
2161
|
+
lastFtsNodeCount = allNodes.length;
|
|
2162
|
+
}
|
|
2163
|
+
const searchResult = engine.search(pattern, limit);
|
|
2164
|
+
let ftsResults = searchResult.results;
|
|
2165
|
+
if (kind) {
|
|
2166
|
+
ftsResults = ftsResults.filter((r) => r.kind === kind);
|
|
2167
|
+
}
|
|
2168
|
+
if (filePattern) {
|
|
2169
|
+
const fp = filePattern.toLowerCase();
|
|
2170
|
+
ftsResults = ftsResults.filter((r) => r.filePath.toLowerCase().includes(fp));
|
|
2171
|
+
}
|
|
2172
|
+
const results2 = ftsResults.slice(0, limit).map((r) => {
|
|
2173
|
+
const node = graph.getNode(r.symbolId);
|
|
2174
|
+
return {
|
|
2175
|
+
symbolId: r.symbolId,
|
|
2176
|
+
name: r.name,
|
|
2177
|
+
kind: r.kind,
|
|
2178
|
+
file: r.filePath,
|
|
2179
|
+
startLine: node?.startLine ?? 0,
|
|
2180
|
+
endLine: node?.endLine ?? 0,
|
|
2181
|
+
relevanceScore: Math.round(r.relevanceScore * 1e3) / 1e3
|
|
2182
|
+
};
|
|
2183
|
+
});
|
|
2184
|
+
const payload2 = masking.mask(JSON.stringify(wrapResponse({
|
|
2185
|
+
totalMatches: ftsResults.length,
|
|
2186
|
+
results: results2,
|
|
2187
|
+
searchMetrics: searchResult.metrics,
|
|
2188
|
+
...searchResult.fuzzyCorrection ? { fuzzyCorrection: searchResult.fuzzyCorrection } : {}
|
|
2189
|
+
})));
|
|
2190
|
+
const content2 = [];
|
|
2191
|
+
if (staleness) {
|
|
2192
|
+
const warning = staleness.check(storage.listIndexedFiles());
|
|
2193
|
+
if (warning) content2.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
|
|
2194
|
+
}
|
|
2195
|
+
content2.push({ type: "text", text: payload2 });
|
|
2196
|
+
return { content: content2 };
|
|
2197
|
+
}
|
|
1467
2198
|
let matcher;
|
|
1468
2199
|
try {
|
|
1469
2200
|
const regex = new RegExp(pattern, "i");
|
|
@@ -2119,26 +2850,85 @@ function handleGetPrImpact(storage, git, masking, staleness, ctxoRoot = ".ctxo")
|
|
|
2119
2850
|
};
|
|
2120
2851
|
}
|
|
2121
2852
|
|
|
2853
|
+
// src/adapters/stats/with-recording.ts
|
|
2854
|
+
function withRecording(toolName, handler, recorder) {
|
|
2855
|
+
if (!recorder) return handler;
|
|
2856
|
+
return (args) => {
|
|
2857
|
+
const start = performance.now();
|
|
2858
|
+
const resultOrPromise = handler(args);
|
|
2859
|
+
if (resultOrPromise instanceof Promise) {
|
|
2860
|
+
return resultOrPromise.then((result) => {
|
|
2861
|
+
recordEvent(toolName, args, result, performance.now() - start, recorder);
|
|
2862
|
+
return result;
|
|
2863
|
+
});
|
|
2864
|
+
}
|
|
2865
|
+
const latencyMs = performance.now() - start;
|
|
2866
|
+
recordEvent(toolName, args, resultOrPromise, latencyMs, recorder);
|
|
2867
|
+
return resultOrPromise;
|
|
2868
|
+
};
|
|
2869
|
+
}
|
|
2870
|
+
function recordEvent(toolName, args, result, latencyMs, recorder) {
|
|
2871
|
+
try {
|
|
2872
|
+
const responseText = result.content.filter((c) => c.type === "text").map((c) => c.text).join("");
|
|
2873
|
+
let truncated = false;
|
|
2874
|
+
try {
|
|
2875
|
+
const parsed = JSON.parse(responseText);
|
|
2876
|
+
truncated = parsed?._meta?.truncated ?? false;
|
|
2877
|
+
} catch {
|
|
2878
|
+
}
|
|
2879
|
+
const responseBytes = Buffer.byteLength(responseText, "utf-8");
|
|
2880
|
+
const rawLevel = args["level"];
|
|
2881
|
+
let detailLevel = null;
|
|
2882
|
+
if (typeof rawLevel === "number" && rawLevel >= 1 && rawLevel <= 4) {
|
|
2883
|
+
detailLevel = `L${rawLevel}`;
|
|
2884
|
+
}
|
|
2885
|
+
const event = {
|
|
2886
|
+
tool: toolName,
|
|
2887
|
+
symbolId: typeof args["symbolId"] === "string" ? args["symbolId"] : null,
|
|
2888
|
+
detailLevel,
|
|
2889
|
+
responseTokens: Math.ceil(responseBytes / 4),
|
|
2890
|
+
responseBytes,
|
|
2891
|
+
latencyMs,
|
|
2892
|
+
truncated
|
|
2893
|
+
};
|
|
2894
|
+
recorder.record(event);
|
|
2895
|
+
} catch {
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
|
|
2122
2899
|
// src/index.ts
|
|
2123
|
-
var
|
|
2900
|
+
var log4 = createLogger("ctxo:mcp");
|
|
2124
2901
|
function loadMaskingConfig(ctxoRoot) {
|
|
2125
2902
|
const jsonConfigPath = join2(ctxoRoot, "masking.json");
|
|
2126
2903
|
if (existsSync2(jsonConfigPath)) {
|
|
2127
2904
|
try {
|
|
2128
2905
|
const raw = readFileSync2(jsonConfigPath, "utf-8");
|
|
2129
2906
|
const patterns = JSON.parse(raw);
|
|
2130
|
-
|
|
2907
|
+
log4.info(`Loaded ${patterns.length} custom masking pattern(s)`);
|
|
2131
2908
|
return MaskingPipeline.fromConfig(patterns);
|
|
2132
2909
|
} catch (err) {
|
|
2133
|
-
|
|
2910
|
+
log4.error(`Failed to load masking config: ${err.message}`);
|
|
2134
2911
|
}
|
|
2135
2912
|
}
|
|
2136
2913
|
return new MaskingPipeline();
|
|
2137
2914
|
}
|
|
2915
|
+
function readStatsEnabled(ctxoRoot) {
|
|
2916
|
+
const configPath = join2(ctxoRoot, "config.yaml");
|
|
2917
|
+
if (!existsSync2(configPath)) return true;
|
|
2918
|
+
try {
|
|
2919
|
+
const raw = readFileSync2(configPath, "utf-8");
|
|
2920
|
+
const match = raw.match(/stats:\s*\n\s*enabled:\s*(true|false)/);
|
|
2921
|
+
if (match) return match[1] !== "false";
|
|
2922
|
+
return true;
|
|
2923
|
+
} catch {
|
|
2924
|
+
return true;
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2138
2927
|
async function main() {
|
|
2139
2928
|
const args = process.argv.slice(2);
|
|
2140
|
-
|
|
2141
|
-
|
|
2929
|
+
const httpPortStr = process.env.CTXO_HTTP_PORT || (args.includes("--http") ? args[args.indexOf("--port") + 1] || "3001" : null);
|
|
2930
|
+
if (!httpPortStr && args.length > 0) {
|
|
2931
|
+
const { CliRouter } = await import("./cli-router-H557TJAM.js");
|
|
2142
2932
|
const router = new CliRouter(process.cwd());
|
|
2143
2933
|
await router.route(args);
|
|
2144
2934
|
return;
|
|
@@ -2148,18 +2938,32 @@ async function main() {
|
|
|
2148
2938
|
await storage.init();
|
|
2149
2939
|
const masking = loadMaskingConfig(ctxoRoot);
|
|
2150
2940
|
const git = new SimpleGitAdapter(process.cwd());
|
|
2941
|
+
const recorder = readStatsEnabled(ctxoRoot) ? new SessionRecorderAdapter(storage.getDb(), () => storage.persist()) : null;
|
|
2151
2942
|
const server = new McpServer({ name: "ctxo", version: "0.1.0" });
|
|
2152
2943
|
const { StalenessDetector } = await import("./staleness-detector-VSDPTPX7.js");
|
|
2153
2944
|
const staleness = new StalenessDetector(process.cwd(), ctxoRoot);
|
|
2945
|
+
registerTools(server, storage, masking, git, staleness, recorder, ctxoRoot);
|
|
2946
|
+
if (httpPortStr) {
|
|
2947
|
+
await startHttpTransport(async () => {
|
|
2948
|
+
const s = new McpServer({ name: "ctxo", version: "0.1.0" });
|
|
2949
|
+
registerTools(s, storage, masking, git, staleness, recorder, ctxoRoot);
|
|
2950
|
+
return s;
|
|
2951
|
+
}, parseInt(httpPortStr, 10));
|
|
2952
|
+
} else {
|
|
2953
|
+
const transport = new StdioServerTransport();
|
|
2954
|
+
await server.connect(transport);
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
function registerTools(server, storage, masking, git, staleness, recorder, ctxoRoot = ".ctxo") {
|
|
2154
2958
|
const toolAnnotations = {
|
|
2155
2959
|
readOnlyHint: true,
|
|
2156
2960
|
destructiveHint: false,
|
|
2157
2961
|
idempotentHint: true,
|
|
2158
2962
|
openWorldHint: false
|
|
2159
2963
|
};
|
|
2160
|
-
const logicSliceHandler = handleGetLogicSlice(storage, masking, staleness, ctxoRoot);
|
|
2161
|
-
const whyContextHandler = handleGetWhyContext(storage, git, masking, staleness, ctxoRoot);
|
|
2162
|
-
const changeIntelligenceHandler = handleGetChangeIntelligence(storage, git, masking, staleness, ctxoRoot);
|
|
2964
|
+
const logicSliceHandler = withRecording("get_logic_slice", handleGetLogicSlice(storage, masking, staleness, ctxoRoot), recorder);
|
|
2965
|
+
const whyContextHandler = withRecording("get_why_context", handleGetWhyContext(storage, git, masking, staleness, ctxoRoot), recorder);
|
|
2966
|
+
const changeIntelligenceHandler = withRecording("get_change_intelligence", handleGetChangeIntelligence(storage, git, masking, staleness, ctxoRoot), recorder);
|
|
2163
2967
|
server.registerTool(
|
|
2164
2968
|
"get_logic_slice",
|
|
2165
2969
|
{
|
|
@@ -2172,7 +2976,7 @@ async function main() {
|
|
|
2172
2976
|
},
|
|
2173
2977
|
annotations: toolAnnotations
|
|
2174
2978
|
},
|
|
2175
|
-
(
|
|
2979
|
+
(args) => logicSliceHandler(args)
|
|
2176
2980
|
);
|
|
2177
2981
|
server.registerTool(
|
|
2178
2982
|
"get_why_context",
|
|
@@ -2184,7 +2988,7 @@ async function main() {
|
|
|
2184
2988
|
},
|
|
2185
2989
|
annotations: toolAnnotations
|
|
2186
2990
|
},
|
|
2187
|
-
(
|
|
2991
|
+
(args) => whyContextHandler(args)
|
|
2188
2992
|
);
|
|
2189
2993
|
server.registerTool(
|
|
2190
2994
|
"get_change_intelligence",
|
|
@@ -2195,9 +2999,9 @@ async function main() {
|
|
|
2195
2999
|
},
|
|
2196
3000
|
annotations: toolAnnotations
|
|
2197
3001
|
},
|
|
2198
|
-
(
|
|
3002
|
+
(args) => changeIntelligenceHandler(args)
|
|
2199
3003
|
);
|
|
2200
|
-
const blastRadiusHandler = handleGetBlastRadius(storage, masking, staleness, ctxoRoot);
|
|
3004
|
+
const blastRadiusHandler = withRecording("get_blast_radius", handleGetBlastRadius(storage, masking, staleness, ctxoRoot), recorder);
|
|
2201
3005
|
server.registerTool(
|
|
2202
3006
|
"get_blast_radius",
|
|
2203
3007
|
{
|
|
@@ -2209,9 +3013,9 @@ async function main() {
|
|
|
2209
3013
|
},
|
|
2210
3014
|
annotations: toolAnnotations
|
|
2211
3015
|
},
|
|
2212
|
-
(
|
|
3016
|
+
(args) => blastRadiusHandler(args)
|
|
2213
3017
|
);
|
|
2214
|
-
const overlayHandler = handleGetArchitecturalOverlay(storage, masking, staleness);
|
|
3018
|
+
const overlayHandler = withRecording("get_architectural_overlay", handleGetArchitecturalOverlay(storage, masking, staleness), recorder);
|
|
2215
3019
|
server.registerTool(
|
|
2216
3020
|
"get_architectural_overlay",
|
|
2217
3021
|
{
|
|
@@ -2221,9 +3025,9 @@ async function main() {
|
|
|
2221
3025
|
},
|
|
2222
3026
|
annotations: toolAnnotations
|
|
2223
3027
|
},
|
|
2224
|
-
(
|
|
3028
|
+
(args) => overlayHandler(args)
|
|
2225
3029
|
);
|
|
2226
|
-
const deadCodeHandler = handleFindDeadCode(storage, masking, staleness, ctxoRoot);
|
|
3030
|
+
const deadCodeHandler = withRecording("find_dead_code", handleFindDeadCode(storage, masking, staleness, ctxoRoot), recorder);
|
|
2227
3031
|
server.registerTool(
|
|
2228
3032
|
"find_dead_code",
|
|
2229
3033
|
{
|
|
@@ -2234,9 +3038,9 @@ async function main() {
|
|
|
2234
3038
|
},
|
|
2235
3039
|
annotations: toolAnnotations
|
|
2236
3040
|
},
|
|
2237
|
-
(
|
|
3041
|
+
(args) => deadCodeHandler(args)
|
|
2238
3042
|
);
|
|
2239
|
-
const contextForTaskHandler = handleGetContextForTask(storage, masking, staleness, ctxoRoot);
|
|
3043
|
+
const contextForTaskHandler = withRecording("get_context_for_task", handleGetContextForTask(storage, masking, staleness, ctxoRoot), recorder);
|
|
2240
3044
|
server.registerTool(
|
|
2241
3045
|
"get_context_for_task",
|
|
2242
3046
|
{
|
|
@@ -2248,9 +3052,9 @@ async function main() {
|
|
|
2248
3052
|
},
|
|
2249
3053
|
annotations: toolAnnotations
|
|
2250
3054
|
},
|
|
2251
|
-
(
|
|
3055
|
+
(args) => contextForTaskHandler(args)
|
|
2252
3056
|
);
|
|
2253
|
-
const rankedContextHandler = handleGetRankedContext(storage, masking, staleness, ctxoRoot);
|
|
3057
|
+
const rankedContextHandler = withRecording("get_ranked_context", handleGetRankedContext(storage, masking, staleness, ctxoRoot), recorder);
|
|
2254
3058
|
server.registerTool(
|
|
2255
3059
|
"get_ranked_context",
|
|
2256
3060
|
{
|
|
@@ -2262,9 +3066,9 @@ async function main() {
|
|
|
2262
3066
|
},
|
|
2263
3067
|
annotations: toolAnnotations
|
|
2264
3068
|
},
|
|
2265
|
-
(
|
|
3069
|
+
(args) => rankedContextHandler(args)
|
|
2266
3070
|
);
|
|
2267
|
-
const searchSymbolsHandler = handleSearchSymbols(storage, masking, staleness, ctxoRoot);
|
|
3071
|
+
const searchSymbolsHandler = withRecording("search_symbols", handleSearchSymbols(storage, masking, staleness, ctxoRoot), recorder);
|
|
2268
3072
|
server.registerTool(
|
|
2269
3073
|
"search_symbols",
|
|
2270
3074
|
{
|
|
@@ -2277,9 +3081,9 @@ async function main() {
|
|
|
2277
3081
|
},
|
|
2278
3082
|
annotations: toolAnnotations
|
|
2279
3083
|
},
|
|
2280
|
-
(
|
|
3084
|
+
(args) => searchSymbolsHandler(args)
|
|
2281
3085
|
);
|
|
2282
|
-
const changedSymbolsHandler = handleGetChangedSymbols(storage, git, masking, staleness, ctxoRoot);
|
|
3086
|
+
const changedSymbolsHandler = withRecording("get_changed_symbols", handleGetChangedSymbols(storage, git, masking, staleness, ctxoRoot), recorder);
|
|
2283
3087
|
server.registerTool(
|
|
2284
3088
|
"get_changed_symbols",
|
|
2285
3089
|
{
|
|
@@ -2290,9 +3094,9 @@ async function main() {
|
|
|
2290
3094
|
},
|
|
2291
3095
|
annotations: toolAnnotations
|
|
2292
3096
|
},
|
|
2293
|
-
(
|
|
3097
|
+
(args) => changedSymbolsHandler(args)
|
|
2294
3098
|
);
|
|
2295
|
-
const findImportersHandler = handleFindImporters(storage, masking, staleness, ctxoRoot);
|
|
3099
|
+
const findImportersHandler = withRecording("find_importers", handleFindImporters(storage, masking, staleness, ctxoRoot), recorder);
|
|
2296
3100
|
server.registerTool(
|
|
2297
3101
|
"find_importers",
|
|
2298
3102
|
{
|
|
@@ -2306,9 +3110,9 @@ async function main() {
|
|
|
2306
3110
|
},
|
|
2307
3111
|
annotations: toolAnnotations
|
|
2308
3112
|
},
|
|
2309
|
-
(
|
|
3113
|
+
(args) => findImportersHandler(args)
|
|
2310
3114
|
);
|
|
2311
|
-
const classHierarchyHandler = handleGetClassHierarchy(storage, masking, staleness, ctxoRoot);
|
|
3115
|
+
const classHierarchyHandler = withRecording("get_class_hierarchy", handleGetClassHierarchy(storage, masking, staleness, ctxoRoot), recorder);
|
|
2312
3116
|
server.registerTool(
|
|
2313
3117
|
"get_class_hierarchy",
|
|
2314
3118
|
{
|
|
@@ -2319,9 +3123,9 @@ async function main() {
|
|
|
2319
3123
|
},
|
|
2320
3124
|
annotations: toolAnnotations
|
|
2321
3125
|
},
|
|
2322
|
-
(
|
|
3126
|
+
(args) => classHierarchyHandler(args)
|
|
2323
3127
|
);
|
|
2324
|
-
const symbolImportanceHandler = handleGetSymbolImportance(storage, masking, staleness, ctxoRoot);
|
|
3128
|
+
const symbolImportanceHandler = withRecording("get_symbol_importance", handleGetSymbolImportance(storage, masking, staleness, ctxoRoot), recorder);
|
|
2325
3129
|
server.registerTool(
|
|
2326
3130
|
"get_symbol_importance",
|
|
2327
3131
|
{
|
|
@@ -2334,9 +3138,9 @@ async function main() {
|
|
|
2334
3138
|
},
|
|
2335
3139
|
annotations: toolAnnotations
|
|
2336
3140
|
},
|
|
2337
|
-
(
|
|
3141
|
+
(args) => symbolImportanceHandler(args)
|
|
2338
3142
|
);
|
|
2339
|
-
const prImpactHandler = handleGetPrImpact(storage, git, masking, staleness, ctxoRoot);
|
|
3143
|
+
const prImpactHandler = withRecording("get_pr_impact", handleGetPrImpact(storage, git, masking, staleness, ctxoRoot), recorder);
|
|
2340
3144
|
server.registerTool(
|
|
2341
3145
|
"get_pr_impact",
|
|
2342
3146
|
{
|
|
@@ -2348,16 +3152,14 @@ async function main() {
|
|
|
2348
3152
|
},
|
|
2349
3153
|
annotations: toolAnnotations
|
|
2350
3154
|
},
|
|
2351
|
-
(
|
|
3155
|
+
(args) => prImpactHandler(args)
|
|
2352
3156
|
);
|
|
2353
3157
|
server.resource("ctxo-status", "ctxo://status", async (uri) => ({
|
|
2354
3158
|
contents: [{ uri: uri.href, text: "Ctxo MCP server is running." }]
|
|
2355
3159
|
}));
|
|
2356
|
-
const transport = new StdioServerTransport();
|
|
2357
|
-
await server.connect(transport);
|
|
2358
3160
|
}
|
|
2359
3161
|
main().catch((err) => {
|
|
2360
|
-
|
|
3162
|
+
log4.error("Fatal: %s", err.message);
|
|
2361
3163
|
process.exit(1);
|
|
2362
3164
|
});
|
|
2363
3165
|
//# sourceMappingURL=index.js.map
|