@mcoda/codali 0.1.109 → 0.1.111
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/dist/cli/AskCommand.d.ts.map +1 -1
- package/dist/cli/AskCommand.js +20 -1
- package/dist/docdex/DocdexClient.d.ts +6 -0
- package/dist/docdex/DocdexClient.d.ts.map +1 -1
- package/dist/docdex/DocdexClient.js +2 -0
- package/dist/gateway/CodaliGateway.d.ts +2 -1
- package/dist/gateway/CodaliGateway.d.ts.map +1 -1
- package/dist/gateway/CodaliGateway.js +117 -37
- package/dist/gateway/EvidenceNormalizer.d.ts.map +1 -1
- package/dist/gateway/EvidenceNormalizer.js +11 -0
- package/dist/gateway/GatewayPlanner.d.ts.map +1 -1
- package/dist/gateway/GatewayPlanner.js +37 -1
- package/dist/gateway/GroundingMode.d.ts +35 -0
- package/dist/gateway/GroundingMode.d.ts.map +1 -0
- package/dist/gateway/GroundingMode.js +69 -0
- package/dist/gateway/LocalGatewayProvider.d.ts +13 -0
- package/dist/gateway/LocalGatewayProvider.d.ts.map +1 -1
- package/dist/gateway/LocalGatewayProvider.js +37 -3
- package/dist/gateway/LocalGatewayTaskRunner.d.ts.map +1 -1
- package/dist/gateway/LocalGatewayTaskRunner.js +18 -2
- package/dist/gateway/RelevanceJudge.d.ts +11 -0
- package/dist/gateway/RelevanceJudge.d.ts.map +1 -1
- package/dist/gateway/RelevanceJudge.js +54 -0
- package/dist/providers/OpenAiCompatibleProvider.d.ts +1 -0
- package/dist/providers/OpenAiCompatibleProvider.d.ts.map +1 -1
- package/dist/providers/OpenAiCompatibleProvider.js +45 -2
- package/dist/tools/ToolRegistry.d.ts +5 -1
- package/dist/tools/ToolRegistry.d.ts.map +1 -1
- package/dist/tools/ToolRegistry.js +79 -2
- package/dist/tools/docdex/DocdexTools.d.ts +13 -0
- package/dist/tools/docdex/DocdexTools.d.ts.map +1 -1
- package/dist/tools/docdex/DocdexTools.js +206 -9
- package/package.json +3 -3
|
@@ -59,6 +59,154 @@ const hasWebFindings = (payload) => {
|
|
|
59
59
|
const fetches = discovery.fetches;
|
|
60
60
|
return Array.isArray(fetches) && fetches.length > 0;
|
|
61
61
|
};
|
|
62
|
+
/**
|
|
63
|
+
* Whether a web-research payload carries anything quotable. Docdex answers a
|
|
64
|
+
* cache hit with discovery metadata only, which reads as success but gives a
|
|
65
|
+
* model nothing to cite.
|
|
66
|
+
*/
|
|
67
|
+
const hasWebContent = (payload) => {
|
|
68
|
+
if (!isRecord(payload))
|
|
69
|
+
return false;
|
|
70
|
+
// Fetched pages first. `hits` is not evidence of a successful fetch: with
|
|
71
|
+
// forceWeb it still carries local index matches, so judging by it alone
|
|
72
|
+
// reports success for a call that downloaded nothing.
|
|
73
|
+
const discovery = isRecord(payload.webDiscovery) ? payload.webDiscovery : undefined;
|
|
74
|
+
const fetches = Array.isArray(discovery?.fetches) ? discovery.fetches : [];
|
|
75
|
+
if (fetches.some((page) => isRecord(page) && (page.ai_digested_content || page.content))) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
for (const key of ["web_context", "webContext"]) {
|
|
79
|
+
const value = payload[key];
|
|
80
|
+
if (Array.isArray(value) && value.length > 0)
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Rewrites a path into the repo-relative form docdex accepts.
|
|
87
|
+
*
|
|
88
|
+
* Docdex answers `invalid path` for a leading slash, a `./` prefix, or an
|
|
89
|
+
* absolute path, and a model asked to list a repository reaches for exactly
|
|
90
|
+
* those. That took out every "which files are in X" question: the tool was
|
|
91
|
+
* chosen correctly and then rejected on a formatting detail. Normalising here
|
|
92
|
+
* is the adapter's job — the caller meant the same directory either way.
|
|
93
|
+
*
|
|
94
|
+
* Returns undefined for "the whole repository", which is what docdex wants when
|
|
95
|
+
* no path is given.
|
|
96
|
+
*/
|
|
97
|
+
export const toRepoRelativePath = (value, workspaceRoot) => {
|
|
98
|
+
if (typeof value !== "string")
|
|
99
|
+
return undefined;
|
|
100
|
+
let candidate = value.trim();
|
|
101
|
+
if (!candidate || candidate === "." || candidate === "./" || candidate === "/") {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
if (workspaceRoot && candidate.startsWith(workspaceRoot)) {
|
|
105
|
+
candidate = candidate.slice(workspaceRoot.length);
|
|
106
|
+
}
|
|
107
|
+
candidate = candidate.replace(/^\.\//, "").replace(/^\/+/, "").replace(/\/{2,}/g, "/");
|
|
108
|
+
return candidate || undefined;
|
|
109
|
+
};
|
|
110
|
+
/** Pages to keep from a web research call, and how much of each. */
|
|
111
|
+
const MAX_WEB_PAGES = 6;
|
|
112
|
+
const MAX_WEB_PAGE_CHARS = 6000;
|
|
113
|
+
/**
|
|
114
|
+
* Reduces a web-research response to the pages it actually fetched.
|
|
115
|
+
*
|
|
116
|
+
* The raw response is the wrong shape twice over. The fetched pages — the whole
|
|
117
|
+
* point of the call — are buried at `webDiscovery.fetches`, where the evidence
|
|
118
|
+
* normalizer does not look, while `hits` holds whatever the *local* index
|
|
119
|
+
* matched. For an external question those hits are repo source files, so a
|
|
120
|
+
* question about a central bank's interest rate came back citing eight files
|
|
121
|
+
* from this repository and none of the eight pages just downloaded.
|
|
122
|
+
*
|
|
123
|
+
* Suppressing local hits by asking docdex to skip its local search does not
|
|
124
|
+
* work: docdex indexes the pages it fetches and serves them back through that
|
|
125
|
+
* same path, so skipping it returns nothing at all. The response has to be
|
|
126
|
+
* reshaped here instead.
|
|
127
|
+
*/
|
|
128
|
+
const toWebResearchOutput = (payload) => {
|
|
129
|
+
if (!isRecord(payload))
|
|
130
|
+
return payload;
|
|
131
|
+
const discovery = isRecord(payload.webDiscovery) ? payload.webDiscovery : undefined;
|
|
132
|
+
const fetches = Array.isArray(discovery?.fetches) ? discovery.fetches : [];
|
|
133
|
+
if (fetches.length === 0)
|
|
134
|
+
return payload;
|
|
135
|
+
const pages = fetches
|
|
136
|
+
.filter(isRecord)
|
|
137
|
+
.filter((page) => typeof page.url === "string")
|
|
138
|
+
.sort((a, b) => Number(b.relevance_score ?? 0) - Number(a.relevance_score ?? 0))
|
|
139
|
+
.slice(0, MAX_WEB_PAGES)
|
|
140
|
+
.map((page) => {
|
|
141
|
+
// The digest is the model-written summary docdex already produced; it
|
|
142
|
+
// says more per character than the raw page and costs the run less.
|
|
143
|
+
const body = (typeof page.ai_digested_content === "string" && page.ai_digested_content) ||
|
|
144
|
+
(typeof page.content === "string" && page.content) ||
|
|
145
|
+
"";
|
|
146
|
+
return {
|
|
147
|
+
url: page.url,
|
|
148
|
+
content: body.slice(0, MAX_WEB_PAGE_CHARS),
|
|
149
|
+
...(page.relevance_score !== undefined ? { relevanceScore: page.relevance_score } : {}),
|
|
150
|
+
};
|
|
151
|
+
})
|
|
152
|
+
.filter((page) => page.content.trim().length > 0);
|
|
153
|
+
if (pages.length === 0)
|
|
154
|
+
return payload;
|
|
155
|
+
return {
|
|
156
|
+
query: isRecord(discovery?.discovery) ? discovery.discovery.query : undefined,
|
|
157
|
+
pages,
|
|
158
|
+
};
|
|
159
|
+
};
|
|
160
|
+
/** Hits to re-snippet. Enough to cover the answer, few enough to stay quick. */
|
|
161
|
+
const QUERY_SNIPPET_HITS = 3;
|
|
162
|
+
const QUERY_SNIPPET_WINDOW = 8;
|
|
163
|
+
/**
|
|
164
|
+
* Replaces each hit's snippet with the part of the file that matches the query.
|
|
165
|
+
*
|
|
166
|
+
* Search returns a window starting at line one, which for a code file is the
|
|
167
|
+
* import block. Asked for the value of a constant, a worker therefore received
|
|
168
|
+
* three files' worth of imports and reported that it could not find the value —
|
|
169
|
+
* correct about what it was given, useless as an answer. The follow-up tools
|
|
170
|
+
* exist (`docdex_open`, `docdex_symbols`) and the tool description recommends
|
|
171
|
+
* them, but a small model reliably searches once and stops, so the first result
|
|
172
|
+
* has to carry the content.
|
|
173
|
+
*
|
|
174
|
+
* Best-effort throughout: a failed re-snippet leaves the original hit alone,
|
|
175
|
+
* because a worse snippet is much better than a failed search.
|
|
176
|
+
*/
|
|
177
|
+
const withQueryCentredSnippets = async (client, query, payload) => {
|
|
178
|
+
if (!isRecord(payload))
|
|
179
|
+
return payload;
|
|
180
|
+
const key = ["hits", "results", "matches"].find((candidate) => Array.isArray(payload[candidate]));
|
|
181
|
+
if (!key)
|
|
182
|
+
return payload;
|
|
183
|
+
const hits = payload[key];
|
|
184
|
+
if (hits.length === 0)
|
|
185
|
+
return payload;
|
|
186
|
+
const enriched = await Promise.all(hits.map(async (hit, index) => {
|
|
187
|
+
if (index >= QUERY_SNIPPET_HITS || !isRecord(hit))
|
|
188
|
+
return hit;
|
|
189
|
+
const docId = hit.doc_id ?? hit.docId ?? hit.rel_path ?? hit.relPath;
|
|
190
|
+
if (typeof docId !== "string" || !docId)
|
|
191
|
+
return hit;
|
|
192
|
+
try {
|
|
193
|
+
const snippet = await client.openSnippet(docId, {
|
|
194
|
+
query,
|
|
195
|
+
window: QUERY_SNIPPET_WINDOW,
|
|
196
|
+
});
|
|
197
|
+
const text = isRecord(snippet) && isRecord(snippet.snippet)
|
|
198
|
+
? snippet.snippet.text
|
|
199
|
+
: undefined;
|
|
200
|
+
if (typeof text !== "string" || text.trim().length === 0)
|
|
201
|
+
return hit;
|
|
202
|
+
return { ...hit, snippet: text, snippet_origin: "query_centred" };
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return hit;
|
|
206
|
+
}
|
|
207
|
+
}));
|
|
208
|
+
return { ...payload, [key]: enriched };
|
|
209
|
+
};
|
|
62
210
|
const withDocdexMetadata = (tools) => tools.map((tool) => ({
|
|
63
211
|
...tool,
|
|
64
212
|
readOnly: tool.readOnly ?? !DOCDEX_WRITE_TOOLS.has(tool.name),
|
|
@@ -93,7 +241,12 @@ const docdexToolDefinitions = (client, options) => [
|
|
|
93
241
|
},
|
|
94
242
|
{
|
|
95
243
|
name: "docdex_search",
|
|
96
|
-
description: "Search the indexed repository and organizational memory for code, docs, and prior decisions.
|
|
244
|
+
description: "Search the indexed repository and organizational memory for code, docs, and prior decisions. " +
|
|
245
|
+
"Searches locally first and falls back to the web when the local index has nothing relevant, " +
|
|
246
|
+
"so this is the default tool for both repo questions and general external questions. " +
|
|
247
|
+
"Returns file paths, doc ids, and for the top results a snippet taken from the part of the " +
|
|
248
|
+
"file that matches the query. For more of a file, follow up with docdex_symbols for its " +
|
|
249
|
+
"signatures or docdex_open for a specific range of lines.",
|
|
97
250
|
inputSchema: {
|
|
98
251
|
type: "object",
|
|
99
252
|
required: ["query"],
|
|
@@ -134,7 +287,7 @@ const docdexToolDefinitions = (client, options) => [
|
|
|
134
287
|
// simply found nothing. Return the local (empty) result instead.
|
|
135
288
|
}
|
|
136
289
|
}
|
|
137
|
-
return toOutput(local);
|
|
290
|
+
return toOutput(await withQueryCentredSnippets(client, query, local));
|
|
138
291
|
},
|
|
139
292
|
},
|
|
140
293
|
{
|
|
@@ -168,7 +321,9 @@ const docdexToolDefinitions = (client, options) => [
|
|
|
168
321
|
},
|
|
169
322
|
{
|
|
170
323
|
name: "docdex_symbols",
|
|
171
|
-
description: "List the definitions in a file with their exact signatures and line numbers
|
|
324
|
+
description: "List the definitions in a file with their exact signatures and line numbers — functions, " +
|
|
325
|
+
"classes, methods, exported constants. Use after docdex_search to find out what a file " +
|
|
326
|
+
"actually contains, since search snippets only show the file's opening lines.",
|
|
172
327
|
inputSchema: {
|
|
173
328
|
type: "object",
|
|
174
329
|
required: ["path"],
|
|
@@ -255,7 +410,7 @@ const docdexToolDefinitions = (client, options) => [
|
|
|
255
410
|
},
|
|
256
411
|
{
|
|
257
412
|
name: "docdex_tree",
|
|
258
|
-
description: "
|
|
413
|
+
description: "List the files and folders in the repository, or under one directory, with build output and vendor directories excluded. This is the tool for \"which files are in X\" and \"what packages exist\" — a text search ranks files by content and will not enumerate them.",
|
|
259
414
|
inputSchema: {
|
|
260
415
|
type: "object",
|
|
261
416
|
properties: {
|
|
@@ -266,10 +421,22 @@ const docdexToolDefinitions = (client, options) => [
|
|
|
266
421
|
extraExcludes: { type: "array", items: { type: "string" } },
|
|
267
422
|
},
|
|
268
423
|
},
|
|
269
|
-
handler: async (args) => {
|
|
424
|
+
handler: async (args, context) => {
|
|
270
425
|
const { path, maxDepth, dirsOnly, includeHidden, extraExcludes } = args;
|
|
271
|
-
const
|
|
272
|
-
|
|
426
|
+
const scoped = toRepoRelativePath(path, context?.workspaceRoot);
|
|
427
|
+
const options = { maxDepth, dirsOnly, includeHidden, extraExcludes };
|
|
428
|
+
try {
|
|
429
|
+
return toOutput(await client.tree({ path: scoped, ...options }));
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
// Docdex fails a directory that does not exist, and a model asked to
|
|
433
|
+
// list a repository guesses names — "mcoda", "monorepo", "root". The
|
|
434
|
+
// request was still "show me what is here", so answer that from the
|
|
435
|
+
// top rather than returning nothing.
|
|
436
|
+
if (!scoped)
|
|
437
|
+
throw error;
|
|
438
|
+
return toOutput(await client.tree(options));
|
|
439
|
+
}
|
|
273
440
|
},
|
|
274
441
|
},
|
|
275
442
|
{
|
|
@@ -374,8 +541,38 @@ const docdexToolDefinitions = (client, options) => [
|
|
|
374
541
|
},
|
|
375
542
|
handler: async (args) => {
|
|
376
543
|
const { query, forceWeb, skipLocalSearch, webLimit, noCache } = args;
|
|
377
|
-
|
|
378
|
-
|
|
544
|
+
// This tool means "go and look outside". Left to default, docdex searches
|
|
545
|
+
// the local index first and returns whatever it holds, so a question about
|
|
546
|
+
// a central bank's interest rate came back sourced to this repository's
|
|
547
|
+
// own documentation. Anything genuinely local is what `docdex_search` is
|
|
548
|
+
// for; a caller can still ask for the mixed behaviour explicitly.
|
|
549
|
+
let result = await client.webResearch(query, {
|
|
550
|
+
forceWeb: forceWeb ?? true,
|
|
551
|
+
skipLocalSearch,
|
|
552
|
+
webLimit,
|
|
553
|
+
noCache,
|
|
554
|
+
});
|
|
555
|
+
// A cache hit returns discovery metadata with no page content at all —
|
|
556
|
+
// no web_context, no hits — so the run sees that a search happened and
|
|
557
|
+
// nothing it can quote. Retrying uncached fetches the pages the cache
|
|
558
|
+
// was standing in for.
|
|
559
|
+
if (!noCache && !hasWebContent(result)) {
|
|
560
|
+
try {
|
|
561
|
+
const fresh = await client.webResearch(query, {
|
|
562
|
+
forceWeb: forceWeb ?? true,
|
|
563
|
+
skipLocalSearch,
|
|
564
|
+
webLimit,
|
|
565
|
+
noCache: true,
|
|
566
|
+
});
|
|
567
|
+
if (hasWebContent(fresh)) {
|
|
568
|
+
result = fresh;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
// Keep the cached response rather than failing a search that worked.
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return toOutput(toWebResearchOutput(result));
|
|
379
576
|
},
|
|
380
577
|
},
|
|
381
578
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mcoda/codali",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.111",
|
|
4
4
|
"description": "Standalone tool-runner adapter for mcoda.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
38
|
-
"@mcoda/
|
|
39
|
-
"@mcoda/
|
|
38
|
+
"@mcoda/shared": "0.1.111",
|
|
39
|
+
"@mcoda/db": "0.1.111"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
42
|
"build": "tsc -p tsconfig.json",
|