@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.
Files changed (33) hide show
  1. package/dist/cli/AskCommand.d.ts.map +1 -1
  2. package/dist/cli/AskCommand.js +20 -1
  3. package/dist/docdex/DocdexClient.d.ts +6 -0
  4. package/dist/docdex/DocdexClient.d.ts.map +1 -1
  5. package/dist/docdex/DocdexClient.js +2 -0
  6. package/dist/gateway/CodaliGateway.d.ts +2 -1
  7. package/dist/gateway/CodaliGateway.d.ts.map +1 -1
  8. package/dist/gateway/CodaliGateway.js +117 -37
  9. package/dist/gateway/EvidenceNormalizer.d.ts.map +1 -1
  10. package/dist/gateway/EvidenceNormalizer.js +11 -0
  11. package/dist/gateway/GatewayPlanner.d.ts.map +1 -1
  12. package/dist/gateway/GatewayPlanner.js +37 -1
  13. package/dist/gateway/GroundingMode.d.ts +35 -0
  14. package/dist/gateway/GroundingMode.d.ts.map +1 -0
  15. package/dist/gateway/GroundingMode.js +69 -0
  16. package/dist/gateway/LocalGatewayProvider.d.ts +13 -0
  17. package/dist/gateway/LocalGatewayProvider.d.ts.map +1 -1
  18. package/dist/gateway/LocalGatewayProvider.js +37 -3
  19. package/dist/gateway/LocalGatewayTaskRunner.d.ts.map +1 -1
  20. package/dist/gateway/LocalGatewayTaskRunner.js +18 -2
  21. package/dist/gateway/RelevanceJudge.d.ts +11 -0
  22. package/dist/gateway/RelevanceJudge.d.ts.map +1 -1
  23. package/dist/gateway/RelevanceJudge.js +54 -0
  24. package/dist/providers/OpenAiCompatibleProvider.d.ts +1 -0
  25. package/dist/providers/OpenAiCompatibleProvider.d.ts.map +1 -1
  26. package/dist/providers/OpenAiCompatibleProvider.js +45 -2
  27. package/dist/tools/ToolRegistry.d.ts +5 -1
  28. package/dist/tools/ToolRegistry.d.ts.map +1 -1
  29. package/dist/tools/ToolRegistry.js +79 -2
  30. package/dist/tools/docdex/DocdexTools.d.ts +13 -0
  31. package/dist/tools/docdex/DocdexTools.d.ts.map +1 -1
  32. package/dist/tools/docdex/DocdexTools.js +206 -9
  33. 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. Ranked snippets with file paths and doc ids. Searches locally first and falls back to the web when the local index has nothing relevant, so this is the default tool for both repo questions and general external questions.",
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 - functions, classes, methods, exported constants. Use to confirm where something is defined before relying on it.",
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: "Render the repository folder tree with build output and vendor directories excluded. Use to orient in an unfamiliar repo instead of listing directories manually.",
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 result = await client.tree({ path, maxDepth, dirsOnly, includeHidden, extraExcludes });
272
- return toOutput(result);
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
- const result = await client.webResearch(query, { forceWeb, skipLocalSearch, webLimit, noCache });
378
- return toOutput(result);
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.109",
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/db": "0.1.109",
39
- "@mcoda/shared": "0.1.109"
38
+ "@mcoda/shared": "0.1.111",
39
+ "@mcoda/db": "0.1.111"
40
40
  },
41
41
  "scripts": {
42
42
  "build": "tsc -p tsconfig.json",