kibi-mcp 0.3.2 → 0.4.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/dist/diagnostics.js +124 -0
- package/dist/server/docs.js +21 -6
- package/dist/server/tools.js +76 -3
- package/dist/server.js +8 -1
- package/dist/tools/check.js +19 -3
- package/dist/tools/core-module.js +83 -0
- package/dist/tools/coverage.js +28 -0
- package/dist/tools/entity-query.js +110 -0
- package/dist/tools/find-gaps.js +29 -0
- package/dist/tools/graph.js +27 -0
- package/dist/tools/query.js +4 -111
- package/dist/tools/search.js +37 -0
- package/dist/tools/status.js +20 -0
- package/dist/tools-config.js +197 -2
- package/package.json +5 -4
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Kibi — repo-local, per-branch, queryable long-term memory for software projects
|
|
3
|
+
Copyright (C) 2026 Piotr Franczyk
|
|
4
|
+
|
|
5
|
+
This program is free software: you can redistribute it and/or modify
|
|
6
|
+
it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
(at your option) any later version.
|
|
9
|
+
|
|
10
|
+
This program is distributed in the hope that it will be useful,
|
|
11
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
GNU Affero General Public License for more details.
|
|
14
|
+
|
|
15
|
+
You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
*/
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { resolveWorkspaceRoot } from "./workspace.js";
|
|
21
|
+
const DIAGNOSTIC_MODE_FLAG = "--diagnostic-mode";
|
|
22
|
+
/**
|
|
23
|
+
* Whether diagnostic mode is enabled via CLI flag.
|
|
24
|
+
* Set during server startup and never changes at runtime.
|
|
25
|
+
*/
|
|
26
|
+
export const DIAGNOSTIC_MODE_ENABLED = process.argv.includes(DIAGNOSTIC_MODE_FLAG);
|
|
27
|
+
/**
|
|
28
|
+
* Path to the diagnostic usage log file.
|
|
29
|
+
* Only valid when DIAGNOSTIC_MODE_ENABLED is true.
|
|
30
|
+
*/
|
|
31
|
+
let diagnosticUsageLogPath = null;
|
|
32
|
+
/**
|
|
33
|
+
* Initialize diagnostic mode: set up usage.log path.
|
|
34
|
+
* Called once during server startup.
|
|
35
|
+
*/
|
|
36
|
+
export function initializeDiagnosticMode() {
|
|
37
|
+
if (DIAGNOSTIC_MODE_ENABLED) {
|
|
38
|
+
const workspaceRoot = resolveWorkspaceRoot();
|
|
39
|
+
diagnosticUsageLogPath = path.join(workspaceRoot, ".kb", "usage.log");
|
|
40
|
+
process.env.KIBI_MCP_DIAGNOSTIC_MODE = "1";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Append a JSON line to the usage.log file.
|
|
45
|
+
* No-op if diagnostic mode is not enabled.
|
|
46
|
+
*/
|
|
47
|
+
export function appendUsageLogLine(entry) {
|
|
48
|
+
if (!DIAGNOSTIC_MODE_ENABLED || !diagnosticUsageLogPath) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const logDir = path.dirname(diagnosticUsageLogPath);
|
|
52
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
53
|
+
fs.appendFileSync(diagnosticUsageLogPath, `${JSON.stringify(entry)}\n`, {
|
|
54
|
+
encoding: "utf8",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Schema for _diagnostic_telemetry field added to tool inputs in diagnostic mode.
|
|
59
|
+
*/
|
|
60
|
+
export const DIAGNOSTIC_TELEMETRY_SCHEMA = {
|
|
61
|
+
type: "object",
|
|
62
|
+
description: "REQUIRED when diagnostic mode is on. Provide self-reflection metadata about this tool call.",
|
|
63
|
+
properties: {
|
|
64
|
+
is_autonomous: {
|
|
65
|
+
type: "boolean",
|
|
66
|
+
description: "Set to TRUE if you are calling this tool on your own initiative to retrieve context. Set to FALSE if the user explicitly commanded you to use the knowledge base.",
|
|
67
|
+
},
|
|
68
|
+
reasoning: {
|
|
69
|
+
type: "string",
|
|
70
|
+
description: "A brief, 1-2 sentence internal thought explaining exactly why you are calling this tool right now and what information you expect to get.",
|
|
71
|
+
},
|
|
72
|
+
confidence_score: {
|
|
73
|
+
type: "number",
|
|
74
|
+
description: "A score from 0.0 to 1.0 representing your confidence that the exact parameters, IDs, or tags you provided will yield a successful result.",
|
|
75
|
+
},
|
|
76
|
+
attempt_number: {
|
|
77
|
+
type: "integer",
|
|
78
|
+
description: "If you are retrying this exact task because a previous tool call failed or returned empty results, increment this number (start at 1).",
|
|
79
|
+
},
|
|
80
|
+
missing_context: {
|
|
81
|
+
type: "string",
|
|
82
|
+
description: "If you had to split your task into multiple steps because this tool lacks a specific filtering or querying capability, describe what parameter is missing. Otherwise, leave empty.",
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
// implements REQ-002
|
|
87
|
+
export function deriveDiagnosticFields(toolName, args, telemetry, result) {
|
|
88
|
+
const fields = {
|
|
89
|
+
telemetry_status: telemetry ? "provided" : "missing",
|
|
90
|
+
};
|
|
91
|
+
const structuredContent = result && typeof result === "object" && "structuredContent" in result
|
|
92
|
+
? result.structuredContent
|
|
93
|
+
: undefined;
|
|
94
|
+
if (toolName === "kb_query" || toolName === "kb_search") {
|
|
95
|
+
const resultCount = Number(structuredContent?.count ?? 0);
|
|
96
|
+
fields.result_count = resultCount;
|
|
97
|
+
fields.zero_results = resultCount === 0;
|
|
98
|
+
fields.result_summary =
|
|
99
|
+
resultCount === 0 ? "0 results" : `${resultCount} results`;
|
|
100
|
+
}
|
|
101
|
+
if (toolName === "kb_check") {
|
|
102
|
+
const violationCount = Number(structuredContent?.count ?? 0);
|
|
103
|
+
fields.violation_count = violationCount;
|
|
104
|
+
fields.requested_rules = Array.isArray(args.rules) ? args.rules : [];
|
|
105
|
+
fields.result_summary =
|
|
106
|
+
violationCount === 0
|
|
107
|
+
? "0 violations"
|
|
108
|
+
: `${violationCount} violations`;
|
|
109
|
+
}
|
|
110
|
+
if (!fields.result_summary) {
|
|
111
|
+
fields.result_summary = `${toolName} completed`;
|
|
112
|
+
}
|
|
113
|
+
return fields;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Extract business args and telemetry from tool call arguments.
|
|
117
|
+
*/
|
|
118
|
+
export function extractToolCallPayload(args) {
|
|
119
|
+
const { _diagnostic_telemetry, ...businessArgs } = args;
|
|
120
|
+
const telemetry = _diagnostic_telemetry && typeof _diagnostic_telemetry === "object"
|
|
121
|
+
? _diagnostic_telemetry
|
|
122
|
+
: null;
|
|
123
|
+
return { businessArgs, telemetry };
|
|
124
|
+
}
|
package/dist/server/docs.js
CHANGED
|
@@ -104,8 +104,13 @@ export const PROMPTS = [
|
|
|
104
104
|
"",
|
|
105
105
|
"Treat this server as a branch-aware knowledge graph interface for software traceability.",
|
|
106
106
|
"",
|
|
107
|
-
"The server exposes
|
|
108
|
-
"- `
|
|
107
|
+
"The server exposes a curated public tool surface for KB operations:",
|
|
108
|
+
"- `kb_search`: Discovery across metadata and markdown body text",
|
|
109
|
+
"- `kb_query`: Exact lookup of entities by type, ID, tags, or source file",
|
|
110
|
+
"- `kb_status`: Branch, snapshot, and freshness inspection",
|
|
111
|
+
"- `kb_find_gaps`: Bulk missing/present relationship analysis",
|
|
112
|
+
"- `kb_coverage`: Curated coverage reporting",
|
|
113
|
+
"- `kb_graph`: Bounded graph traversal from seed IDs",
|
|
109
114
|
"- `kb_upsert`: Create or update entities and their relationships",
|
|
110
115
|
"- `kb_delete`: Remove entities by ID (with dependency safety checks)",
|
|
111
116
|
"- `kb_check`: Validate KB integrity against configurable rules",
|
|
@@ -113,7 +118,7 @@ export const PROMPTS = [
|
|
|
113
118
|
"Core modeling principles:",
|
|
114
119
|
"- Encode requirements as linked facts: `req --constrains--> fact` plus `req --requires_property--> fact`.",
|
|
115
120
|
"- Reuse canonical fact IDs across requirements; shared constrained facts make contradictions detectable.",
|
|
116
|
-
"- Use `
|
|
121
|
+
"- Use `kb_search` first for discovery, then `kb_query` for exact follow-up before any mutation.",
|
|
117
122
|
"- Use `kb_upsert` and `kb_delete` only for intentional, traceable KB changes.",
|
|
118
123
|
"- Run `kb_check` after meaningful mutations to catch integrity issues early.",
|
|
119
124
|
"- Prefer explicit IDs and enum values to avoid invalid parameters.",
|
|
@@ -128,7 +133,7 @@ export const PROMPTS = [
|
|
|
128
133
|
"",
|
|
129
134
|
"Follow this sequence for reliable operation:",
|
|
130
135
|
"",
|
|
131
|
-
"1. **
|
|
136
|
+
"1. **Discover first**: Call `kb_search` for exploratory discovery, then `kb_query` to confirm exact current state before mutation.",
|
|
132
137
|
"2. **Create-before-link**: Create endpoint entities with `kb_upsert` before linking them.",
|
|
133
138
|
"3. **Validate intent**: If creating links, call `kb_query` for both endpoint IDs first to ensure they exist.",
|
|
134
139
|
"4. **Model requirements as facts**: For new/updated reqs, create/reuse fact entities first, then express req semantics with `constrains` + `requires_property`.",
|
|
@@ -167,8 +172,8 @@ function registerDocResources() {
|
|
|
167
172
|
"kibi-mcp is a stdio MCP server for querying and mutating the Kibi knowledge base.",
|
|
168
173
|
"",
|
|
169
174
|
"Scope:",
|
|
170
|
-
"-
|
|
171
|
-
"-
|
|
175
|
+
"- Read-only discovery and reporting (`kb_search`, `kb_query`, `kb_status`, `kb_find_gaps`, `kb_coverage`, `kb_graph`)",
|
|
176
|
+
"- KB mutation and validation (`kb_upsert`, `kb_delete`, `kb_check`)",
|
|
172
177
|
"- Automatic branch-local attachment for the active workspace",
|
|
173
178
|
"",
|
|
174
179
|
"Use this server when you need branch-local, machine-readable project memory.",
|
|
@@ -188,6 +193,11 @@ function registerDocResources() {
|
|
|
188
193
|
const examples = [
|
|
189
194
|
"# kibi-mcp Examples",
|
|
190
195
|
"",
|
|
196
|
+
"## Discover before mutating",
|
|
197
|
+
'1. `kb_search` with `{ "query": "login flow" }` to discover related requirements, tests, and ADRs',
|
|
198
|
+
'2. `kb_query` with `{ "type": "req", "sourceFile": "src/auth/login.ts" }` for exact follow-up',
|
|
199
|
+
'3. `kb_status` with `{}` when branch attachment or freshness confidence matters',
|
|
200
|
+
"",
|
|
191
201
|
"## Model requirements as reusable facts",
|
|
192
202
|
'1. `kb_query` with `{ "type": "fact" }` to find existing fact IDs before creating new ones',
|
|
193
203
|
"2. `kb_upsert` for the fact entity first (create-before-link)",
|
|
@@ -195,6 +205,11 @@ function registerDocResources() {
|
|
|
195
205
|
"4. Reuse the same constrained fact ID across related requirements; vary property facts only when semantics differ",
|
|
196
206
|
'5. `kb_check` with `{ "rules": ["required-fields","no-dangling-refs"] }` for targeted validation',
|
|
197
207
|
"",
|
|
208
|
+
"## Find missing coverage",
|
|
209
|
+
'1. `kb_find_gaps` with `{ "type": "req", "missingRelationships": ["specified_by", "verified_by"] }` to find under-linked requirements',
|
|
210
|
+
'2. `kb_coverage` with `{ "by": "req", "includePassing": false }` to review evaluated coverage rows',
|
|
211
|
+
'3. `kb_graph` with `{ "seedIds": ["REQ-001"], "direction": "both", "depth": 2 }` to inspect neighboring entities',
|
|
212
|
+
"",
|
|
198
213
|
"## Add a requirement and link it to a test",
|
|
199
214
|
'1. `kb_query` with `{ "type": "test" }` to check for existing test IDs',
|
|
200
215
|
'2. `kb_query` with `{ "id": "REQ-XXX" }` to verify the requirement exists',
|
package/dist/server/tools.js
CHANGED
|
@@ -17,12 +17,18 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import process from "node:process";
|
|
19
19
|
import { z } from "zod";
|
|
20
|
+
import { DIAGNOSTIC_MODE_ENABLED, appendUsageLogLine, deriveDiagnosticFields, extractToolCallPayload, } from "../diagnostics.js";
|
|
21
|
+
import { handleKbCoverage } from "../tools/coverage.js";
|
|
20
22
|
import { TOOLS } from "../tools-config.js";
|
|
21
23
|
import { handleKbCheck } from "../tools/check.js";
|
|
22
24
|
import { handleKbDelete } from "../tools/delete.js";
|
|
25
|
+
import { handleKbFindGaps } from "../tools/find-gaps.js";
|
|
26
|
+
import { handleKbGraph } from "../tools/graph.js";
|
|
23
27
|
import { handleKbQuery } from "../tools/query.js";
|
|
28
|
+
import { handleKbSearch } from "../tools/search.js";
|
|
29
|
+
import { handleKbStatus } from "../tools/status.js";
|
|
24
30
|
import { handleKbUpsert } from "../tools/upsert.js";
|
|
25
|
-
import { ensureProlog, inFlightRequests, isShuttingDown } from "./session.js";
|
|
31
|
+
import { activeBranchName, ensureProlog, inFlightRequests, isShuttingDown, prologProcess, } from "./session.js";
|
|
26
32
|
const ACTIVE_TOOLS = TOOLS;
|
|
27
33
|
function debugLog(...args) {
|
|
28
34
|
if (process.env.KIBI_MCP_DEBUG) {
|
|
@@ -131,12 +137,16 @@ export function jsonSchemaToZod(schema) {
|
|
|
131
137
|
}
|
|
132
138
|
function addTool(server, name, description, inputSchema, handler) {
|
|
133
139
|
const wrappedHandler = async (args) => {
|
|
140
|
+
const startedAt = new Date();
|
|
141
|
+
// Extract telemetry in diagnostic mode
|
|
142
|
+
const { businessArgs, telemetry } = DIAGNOSTIC_MODE_ENABLED
|
|
143
|
+
? extractToolCallPayload(args)
|
|
144
|
+
: { businessArgs: args, telemetry: null };
|
|
134
145
|
try {
|
|
135
146
|
// Validate that args is a valid object
|
|
136
147
|
if (typeof args !== "object" || args === null) {
|
|
137
148
|
throw new Error(`Invalid arguments for tool ${name}: expected object, got ${typeof args}`);
|
|
138
149
|
}
|
|
139
|
-
const businessArgs = args;
|
|
140
150
|
// Check if shutting down before processing
|
|
141
151
|
if (isShuttingDown) {
|
|
142
152
|
throw new Error(`Tool ${name} rejected: server is shutting down`);
|
|
@@ -155,7 +165,49 @@ function addTool(server, name, description, inputSchema, handler) {
|
|
|
155
165
|
inFlightRequests.set(requestId, handlerPromise);
|
|
156
166
|
try {
|
|
157
167
|
// Execute handler
|
|
158
|
-
|
|
168
|
+
const result = await handlerPromise;
|
|
169
|
+
// Log usage in diagnostic mode
|
|
170
|
+
if (DIAGNOSTIC_MODE_ENABLED) {
|
|
171
|
+
const finishedAt = new Date();
|
|
172
|
+
const diagnosticFields = deriveDiagnosticFields(name, businessArgs, telemetry, result);
|
|
173
|
+
appendUsageLogLine({
|
|
174
|
+
timestamp: finishedAt.toISOString(),
|
|
175
|
+
request_id: requestId,
|
|
176
|
+
tool: name,
|
|
177
|
+
telemetry,
|
|
178
|
+
business_args: businessArgs,
|
|
179
|
+
status: "success",
|
|
180
|
+
started_at: startedAt.toISOString(),
|
|
181
|
+
finished_at: finishedAt.toISOString(),
|
|
182
|
+
duration_ms: finishedAt.getTime() - startedAt.getTime(),
|
|
183
|
+
prolog_pid: prologProcess?.getPid() ?? null,
|
|
184
|
+
active_branch: activeBranchName,
|
|
185
|
+
...diagnosticFields,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
// Log error in diagnostic mode
|
|
192
|
+
if (DIAGNOSTIC_MODE_ENABLED) {
|
|
193
|
+
const finishedAt = new Date();
|
|
194
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
195
|
+
appendUsageLogLine({
|
|
196
|
+
timestamp: finishedAt.toISOString(),
|
|
197
|
+
request_id: requestId,
|
|
198
|
+
tool: name,
|
|
199
|
+
telemetry,
|
|
200
|
+
business_args: businessArgs,
|
|
201
|
+
status: "error",
|
|
202
|
+
started_at: startedAt.toISOString(),
|
|
203
|
+
finished_at: finishedAt.toISOString(),
|
|
204
|
+
duration_ms: finishedAt.getTime() - startedAt.getTime(),
|
|
205
|
+
prolog_pid: prologProcess?.getPid() ?? null,
|
|
206
|
+
active_branch: activeBranchName,
|
|
207
|
+
error_message: err.message,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
throw error;
|
|
159
211
|
}
|
|
160
212
|
finally {
|
|
161
213
|
// Always clean up from Map when done (success or failure)
|
|
@@ -173,6 +225,7 @@ function addTool(server, name, description, inputSchema, handler) {
|
|
|
173
225
|
};
|
|
174
226
|
server.registerTool(name, { description, inputSchema: jsonSchemaToZod(inputSchema) }, wrappedHandler);
|
|
175
227
|
}
|
|
228
|
+
// implements REQ-002, REQ-013
|
|
176
229
|
export function registerAllTools(server) {
|
|
177
230
|
const toolDef = (name) => {
|
|
178
231
|
const t = ACTIVE_TOOLS.find((t) => t.name === name);
|
|
@@ -184,6 +237,26 @@ export function registerAllTools(server) {
|
|
|
184
237
|
const prolog = await ensureProlog();
|
|
185
238
|
return handleKbQuery(prolog, args);
|
|
186
239
|
});
|
|
240
|
+
addTool(server, "kb_search", toolDef("kb_search").description, toolDef("kb_search").inputSchema, async (args) => {
|
|
241
|
+
const prolog = await ensureProlog();
|
|
242
|
+
return handleKbSearch(prolog, args);
|
|
243
|
+
});
|
|
244
|
+
addTool(server, "kb_status", toolDef("kb_status").description, toolDef("kb_status").inputSchema, async (args) => {
|
|
245
|
+
const prolog = await ensureProlog();
|
|
246
|
+
return handleKbStatus(prolog, args);
|
|
247
|
+
});
|
|
248
|
+
addTool(server, "kb_find_gaps", toolDef("kb_find_gaps").description, toolDef("kb_find_gaps").inputSchema, async (args) => {
|
|
249
|
+
const prolog = await ensureProlog();
|
|
250
|
+
return handleKbFindGaps(prolog, args);
|
|
251
|
+
});
|
|
252
|
+
addTool(server, "kb_coverage", toolDef("kb_coverage").description, toolDef("kb_coverage").inputSchema, async (args) => {
|
|
253
|
+
const prolog = await ensureProlog();
|
|
254
|
+
return handleKbCoverage(prolog, args);
|
|
255
|
+
});
|
|
256
|
+
addTool(server, "kb_graph", toolDef("kb_graph").description, toolDef("kb_graph").inputSchema, async (args) => {
|
|
257
|
+
const prolog = await ensureProlog();
|
|
258
|
+
return handleKbGraph(prolog, args);
|
|
259
|
+
});
|
|
187
260
|
addTool(server, "kb_upsert", toolDef("kb_upsert").description, toolDef("kb_upsert").inputSchema, async (args) => {
|
|
188
261
|
const prolog = await ensureProlog();
|
|
189
262
|
return handleKbUpsert(prolog, args);
|
package/dist/server.js
CHANGED
|
@@ -15,17 +15,24 @@
|
|
|
15
15
|
You should have received a copy of the GNU Affero General Public License
|
|
16
16
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
17
|
*/
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
18
19
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
19
20
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
21
|
+
import { initializeDiagnosticMode } from "./diagnostics.js";
|
|
20
22
|
import { loadDefaultEnvFile } from "./env.js";
|
|
21
23
|
import { setupDocsAndPrompts } from "./server/docs.js";
|
|
22
24
|
import { registerAllTools } from "./server/tools.js";
|
|
23
25
|
import { connectTransport, setupTransportHandlers, } from "./server/transport.js";
|
|
26
|
+
// Read version from package.json to prevent drift
|
|
27
|
+
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
28
|
+
const VERSION = packageJson.version ?? "0.1.0";
|
|
24
29
|
export async function startServer() {
|
|
25
30
|
// Load environment configuration
|
|
26
31
|
loadDefaultEnvFile();
|
|
32
|
+
// Initialize diagnostic mode if --diagnostic-mode flag is present
|
|
33
|
+
initializeDiagnosticMode();
|
|
27
34
|
// Create MCP server
|
|
28
|
-
const server = new McpServer({ name: "kibi-mcp", version:
|
|
35
|
+
const server = new McpServer({ name: "kibi-mcp", version: VERSION });
|
|
29
36
|
// Setup documentation resources and prompts
|
|
30
37
|
setupDocsAndPrompts(server);
|
|
31
38
|
// Register all KB tools
|
package/dist/tools/check.js
CHANGED
|
@@ -64,6 +64,24 @@ function formatDiagnosticsForMcp(diagnostics) {
|
|
|
64
64
|
suggestion: d.suggestion,
|
|
65
65
|
}));
|
|
66
66
|
}
|
|
67
|
+
function formatViolationText(violations) {
|
|
68
|
+
if (violations.length === 0) {
|
|
69
|
+
return "No violations found";
|
|
70
|
+
}
|
|
71
|
+
const details = violations.map((violation) => {
|
|
72
|
+
const parts = [
|
|
73
|
+
violation.rule,
|
|
74
|
+
violation.entityId,
|
|
75
|
+
violation.source ?? "unknown-source",
|
|
76
|
+
violation.description,
|
|
77
|
+
];
|
|
78
|
+
if (violation.suggestion) {
|
|
79
|
+
parts.push(`Suggestion: ${violation.suggestion}`);
|
|
80
|
+
}
|
|
81
|
+
return `- ${parts.join(" | ")}`;
|
|
82
|
+
});
|
|
83
|
+
return `${violations.length} violations found\n${details.join("\n")}`;
|
|
84
|
+
}
|
|
67
85
|
// implements REQ-002
|
|
68
86
|
function loadChecksConfig(workspaceRoot) {
|
|
69
87
|
const configPath = path.join(workspaceRoot, ".kb", "config.json");
|
|
@@ -151,9 +169,7 @@ export async function handleKbCheck(prolog, args) {
|
|
|
151
169
|
file: v.source,
|
|
152
170
|
suggestion: v.suggestion,
|
|
153
171
|
}));
|
|
154
|
-
const summary = aggregatedViolations
|
|
155
|
-
? "No violations found"
|
|
156
|
-
: `${aggregatedViolations.length} violations found`;
|
|
172
|
+
const summary = formatViolationText(aggregatedViolations);
|
|
157
173
|
return {
|
|
158
174
|
content: [
|
|
159
175
|
{
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { PrologProcess } from "kibi-cli/prolog";
|
|
5
|
+
import { escapeAtomContent } from "kibi-cli/prolog/codec";
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
// implements REQ-002, REQ-013
|
|
8
|
+
export function resolveCorePlPath(fileName) {
|
|
9
|
+
const envKey = `KIBI_${fileName.replace(/\W/g, "_").toUpperCase()}_PATH`;
|
|
10
|
+
const override = process.env[envKey];
|
|
11
|
+
if (override && existsSync(override)) {
|
|
12
|
+
return override;
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
const installedPath = require.resolve(`kibi-core/src/${fileName}`);
|
|
16
|
+
if (existsSync(installedPath)) {
|
|
17
|
+
return installedPath;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
catch { }
|
|
21
|
+
const localPath = path.join(process.cwd(), "packages/core/src", fileName);
|
|
22
|
+
if (existsSync(localPath)) {
|
|
23
|
+
return localPath;
|
|
24
|
+
}
|
|
25
|
+
throw new Error(`Unable to resolve core module path for ${fileName}`);
|
|
26
|
+
}
|
|
27
|
+
// implements REQ-002, REQ-013
|
|
28
|
+
export async function runJsonModuleQuery(prolog, fileName, goal, errorLabel) {
|
|
29
|
+
const modulePath = escapeAtomContent(resolveCorePlPath(fileName).replace(/\\/g, "/"));
|
|
30
|
+
if (!(prolog instanceof PrologProcess)) {
|
|
31
|
+
const mockedResult = await prolog.query(`(use_module('${modulePath}'), ${goal})`);
|
|
32
|
+
if (!mockedResult.success) {
|
|
33
|
+
throw new Error(`${errorLabel} query failed: ${mockedResult.error || "Unknown error"}`);
|
|
34
|
+
}
|
|
35
|
+
const mockedJson = mockedResult.bindings.JsonString;
|
|
36
|
+
if (!mockedJson) {
|
|
37
|
+
throw new Error(`${errorLabel} query returned no JsonString binding`);
|
|
38
|
+
}
|
|
39
|
+
let mockedParsed = JSON.parse(mockedJson);
|
|
40
|
+
if (typeof mockedParsed === "string") {
|
|
41
|
+
mockedParsed = JSON.parse(mockedParsed);
|
|
42
|
+
}
|
|
43
|
+
return mockedParsed;
|
|
44
|
+
}
|
|
45
|
+
const oneShotCapable = prolog;
|
|
46
|
+
prolog.invalidateCache();
|
|
47
|
+
const result = oneShotCapable.useOneShotMode
|
|
48
|
+
? await prolog.query(`(use_module('${modulePath}'), ${goal})`)
|
|
49
|
+
: await runInteractiveModuleQuery(prolog, modulePath, goal, errorLabel);
|
|
50
|
+
if (!result.success) {
|
|
51
|
+
throw new Error(`${errorLabel} query failed: ${result.error || "Unknown error"}`);
|
|
52
|
+
}
|
|
53
|
+
const rawJson = result.bindings.JsonString;
|
|
54
|
+
if (!rawJson) {
|
|
55
|
+
throw new Error(`${errorLabel} query returned no JsonString binding`);
|
|
56
|
+
}
|
|
57
|
+
let parsed = JSON.parse(rawJson);
|
|
58
|
+
if (typeof parsed === "string") {
|
|
59
|
+
parsed = JSON.parse(parsed);
|
|
60
|
+
}
|
|
61
|
+
return parsed;
|
|
62
|
+
}
|
|
63
|
+
async function runInteractiveModuleQuery(prolog, modulePath, goal, errorLabel) {
|
|
64
|
+
const loadResult = await prolog.query(`use_module('${modulePath}')`);
|
|
65
|
+
if (!loadResult.success) {
|
|
66
|
+
throw new Error(`${errorLabel} module load failed: ${loadResult.error || "Unknown error"}`);
|
|
67
|
+
}
|
|
68
|
+
return prolog.query(goal);
|
|
69
|
+
}
|
|
70
|
+
// implements REQ-002, REQ-013
|
|
71
|
+
export function toPrologAtom(value) {
|
|
72
|
+
if (!value) {
|
|
73
|
+
return "none";
|
|
74
|
+
}
|
|
75
|
+
return `'${escapeAtomContent(value)}'`;
|
|
76
|
+
}
|
|
77
|
+
// implements REQ-002, REQ-013
|
|
78
|
+
export function toPrologList(values) {
|
|
79
|
+
if (!values || values.length === 0) {
|
|
80
|
+
return "[]";
|
|
81
|
+
}
|
|
82
|
+
return `[${values.map((value) => `'${escapeAtomContent(value)}'`).join(",")}]`;
|
|
83
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { runJsonModuleQuery, toPrologList } from "./core-module.js";
|
|
2
|
+
// implements REQ-002, REQ-013
|
|
3
|
+
export async function handleKbCoverage(prolog, args) {
|
|
4
|
+
const by = args.by ?? "req";
|
|
5
|
+
const limit = args.limit ?? 100;
|
|
6
|
+
const offset = args.offset ?? 0;
|
|
7
|
+
const includePassing = args.includePassing ?? false;
|
|
8
|
+
const includeTransitive = args.includeTransitive ?? true;
|
|
9
|
+
try {
|
|
10
|
+
const payload = await runJsonModuleQuery(prolog, "discovery.pl", `discovery:coverage_report_json('${by}', ${toPrologList(args.tags)}, ${includePassing}, ${includeTransitive}, ${limit}, ${offset}, JsonString)`, "Coverage execution");
|
|
11
|
+
const summary = payload?.summary ?? {};
|
|
12
|
+
const fullyCovered = Number(summary.fullyCovered ?? 0);
|
|
13
|
+
const total = Number(summary.total ?? 0);
|
|
14
|
+
return {
|
|
15
|
+
content: [
|
|
16
|
+
{
|
|
17
|
+
type: "text",
|
|
18
|
+
text: `Coverage summary: ${fullyCovered} fully covered out of ${total}.`,
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
structuredContent: payload,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
26
|
+
throw new Error(`Coverage execution failed: ${message}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { escapeAtomContent, parseEntityFromBinding, parseEntityFromList, parseListOfLists, } from "kibi-cli/prolog/codec";
|
|
2
|
+
export const VALID_ENTITY_TYPES = [
|
|
3
|
+
"req",
|
|
4
|
+
"scenario",
|
|
5
|
+
"test",
|
|
6
|
+
"adr",
|
|
7
|
+
"flag",
|
|
8
|
+
"event",
|
|
9
|
+
"symbol",
|
|
10
|
+
"fact",
|
|
11
|
+
];
|
|
12
|
+
// implements REQ-002, REQ-013
|
|
13
|
+
export function validateEntityType(type) {
|
|
14
|
+
if (type && !VALID_ENTITY_TYPES.includes(type)) {
|
|
15
|
+
throw new Error(`Invalid type '${type}'. Valid types: ${VALID_ENTITY_TYPES.join(", ")}. Use a single type value, or omit this parameter to query all entities.`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
// implements REQ-002, REQ-013
|
|
19
|
+
export function buildEntityGoal(args) {
|
|
20
|
+
const { type, id, tags, sourceFile } = args;
|
|
21
|
+
if (sourceFile) {
|
|
22
|
+
const safeSource = escapeAtomContent(sourceFile);
|
|
23
|
+
if (type) {
|
|
24
|
+
const safeType = escapeAtomContent(type);
|
|
25
|
+
return `findall([Id,'${safeType}',Props], (kb_entities_by_source('${safeSource}', SourceIds), member(Id, SourceIds), kb_entity(Id, '${safeType}', Props)), Results)`;
|
|
26
|
+
}
|
|
27
|
+
return `findall([Id,Type,Props], (kb_entities_by_source('${safeSource}', SourceIds), member(Id, SourceIds), kb_entity(Id, Type, Props)), Results)`;
|
|
28
|
+
}
|
|
29
|
+
if (id && type) {
|
|
30
|
+
const safeId = escapeAtomContent(id);
|
|
31
|
+
const safeType = escapeAtomContent(type);
|
|
32
|
+
return `findall(['${safeId}','${safeType}',Props], kb_entity('${safeId}', '${safeType}', Props), Results)`;
|
|
33
|
+
}
|
|
34
|
+
if (id) {
|
|
35
|
+
const safeId = escapeAtomContent(id);
|
|
36
|
+
return `findall(['${safeId}',Type,Props], kb_entity('${safeId}', Type, Props), Results)`;
|
|
37
|
+
}
|
|
38
|
+
if (tags && tags.length > 0) {
|
|
39
|
+
if (type) {
|
|
40
|
+
const safeType = escapeAtomContent(type);
|
|
41
|
+
return `findall([Id,'${safeType}',Props], kb_entity(Id, '${safeType}', Props), Results)`;
|
|
42
|
+
}
|
|
43
|
+
return "findall([Id,Type,Props], kb_entity(Id, Type, Props), Results)";
|
|
44
|
+
}
|
|
45
|
+
if (type) {
|
|
46
|
+
const safeType = escapeAtomContent(type);
|
|
47
|
+
return `findall([Id,'${safeType}',Props], kb_entity(Id, '${safeType}', Props), Results)`;
|
|
48
|
+
}
|
|
49
|
+
return "findall([Id,Type,Props], kb_entity(Id, Type, Props), Results)";
|
|
50
|
+
}
|
|
51
|
+
// implements REQ-002, REQ-013
|
|
52
|
+
export async function loadEntities(prolog, args) {
|
|
53
|
+
validateEntityType(args.type);
|
|
54
|
+
const goal = buildEntityGoal(args);
|
|
55
|
+
const queryResult = await prolog.query(goal);
|
|
56
|
+
let results = [];
|
|
57
|
+
if (!queryResult.success) {
|
|
58
|
+
throw new Error(queryResult.error || "Query failed with unknown error");
|
|
59
|
+
}
|
|
60
|
+
if (queryResult.bindings.Results) {
|
|
61
|
+
const entitiesData = parseListOfLists(queryResult.bindings.Results);
|
|
62
|
+
for (const data of entitiesData) {
|
|
63
|
+
results.push(parseEntityFromList(data));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else if (queryResult.bindings.Result) {
|
|
67
|
+
results = [parseEntityFromBinding(queryResult.bindings.Result)];
|
|
68
|
+
}
|
|
69
|
+
if (args.tags && args.tags.length > 0) {
|
|
70
|
+
const requestedTags = args.tags;
|
|
71
|
+
results = dedupeEntities(results.filter((entity) => hasAnyTag(entity, requestedTags)));
|
|
72
|
+
}
|
|
73
|
+
return dedupeEntities(results);
|
|
74
|
+
}
|
|
75
|
+
// implements REQ-002
|
|
76
|
+
export function paginateResults(results, limit = 100, offset = 0) {
|
|
77
|
+
return results.slice(offset, offset + limit);
|
|
78
|
+
}
|
|
79
|
+
function hasAnyTag(entity, requestedTags) {
|
|
80
|
+
const expected = new Set(requestedTags.map(normalizeTagValue));
|
|
81
|
+
const rawTags = entity.tags;
|
|
82
|
+
if (!Array.isArray(rawTags) || rawTags.length === 0) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
for (const tag of rawTags) {
|
|
86
|
+
if (expected.has(normalizeTagValue(tag))) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
function normalizeTagValue(tag) {
|
|
93
|
+
return String(tag).trim();
|
|
94
|
+
}
|
|
95
|
+
// implements REQ-002
|
|
96
|
+
export function dedupeEntities(entities) {
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
const deduped = [];
|
|
99
|
+
for (const entity of entities) {
|
|
100
|
+
const id = String(entity.id ?? "");
|
|
101
|
+
const type = String(entity.type ?? "");
|
|
102
|
+
const key = `${type}::${id}`;
|
|
103
|
+
if (seen.has(key)) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
seen.add(key);
|
|
107
|
+
deduped.push(entity);
|
|
108
|
+
}
|
|
109
|
+
return deduped;
|
|
110
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { runJsonModuleQuery, toPrologAtom, toPrologList } from "./core-module.js";
|
|
2
|
+
import { validateEntityType } from "./entity-query.js";
|
|
3
|
+
// implements REQ-002, REQ-013
|
|
4
|
+
export async function handleKbFindGaps(prolog, args) {
|
|
5
|
+
validateEntityType(args.type);
|
|
6
|
+
const limit = args.limit ?? 100;
|
|
7
|
+
const offset = args.offset ?? 0;
|
|
8
|
+
try {
|
|
9
|
+
const payload = await runJsonModuleQuery(prolog, "discovery.pl", `discovery:find_gaps_json(${toPrologAtom(args.type)}, ${toPrologList(args.missingRelationships)}, ${toPrologList(args.presentRelationships)}, ${toPrologList(args.tags)}, ${toPrologAtom(args.sourceFile)}, ${limit}, ${offset}, JsonString)`, "Find-gaps execution");
|
|
10
|
+
const rows = payload?.rows ?? [];
|
|
11
|
+
return {
|
|
12
|
+
content: [
|
|
13
|
+
{
|
|
14
|
+
type: "text",
|
|
15
|
+
text: rows.length === 0
|
|
16
|
+
? "No matching gaps found."
|
|
17
|
+
: `Found ${payload?.count ?? rows.length} gap rows. Showing ${rows.length}: ${rows
|
|
18
|
+
.map((row) => row.id)
|
|
19
|
+
.join(", ")}`,
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
structuredContent: payload,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
27
|
+
throw new Error(`Find-gaps execution failed: ${message}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { runJsonModuleQuery, toPrologList } from "./core-module.js";
|
|
2
|
+
// implements REQ-002, REQ-013
|
|
3
|
+
export async function handleKbGraph(prolog, args) {
|
|
4
|
+
const direction = args.direction ?? "outgoing";
|
|
5
|
+
const depth = args.depth ?? 1;
|
|
6
|
+
const maxNodes = args.maxNodes ?? 200;
|
|
7
|
+
const maxEdges = args.maxEdges ?? 500;
|
|
8
|
+
try {
|
|
9
|
+
const payload = await runJsonModuleQuery(prolog, "discovery.pl", `discovery:graph_expand_json(${toPrologList(args.seedIds)}, ${toPrologList(args.relationships)}, '${direction}', ${depth}, ${toPrologList(args.entityTypes)}, ${maxNodes}, ${maxEdges}, JsonString)`, "Graph execution");
|
|
10
|
+
const nodes = payload?.nodes ?? [];
|
|
11
|
+
return {
|
|
12
|
+
content: [
|
|
13
|
+
{
|
|
14
|
+
type: "text",
|
|
15
|
+
text: nodes.length === 0
|
|
16
|
+
? "Graph traversal returned no nodes."
|
|
17
|
+
: `Graph traversal returned ${nodes.length} nodes and ${(payload?.edges ?? []).length} edges from ${args.seedIds.join(", ")}.`,
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
structuredContent: payload,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
25
|
+
throw new Error(`Graph execution failed: ${message}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
package/dist/tools/query.js
CHANGED
|
@@ -1,14 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export
|
|
3
|
-
"req",
|
|
4
|
-
"scenario",
|
|
5
|
-
"test",
|
|
6
|
-
"adr",
|
|
7
|
-
"flag",
|
|
8
|
-
"event",
|
|
9
|
-
"symbol",
|
|
10
|
-
"fact",
|
|
11
|
-
];
|
|
1
|
+
import { loadEntities, paginateResults, } from "./entity-query.js";
|
|
2
|
+
export { VALID_ENTITY_TYPES } from "./entity-query.js";
|
|
12
3
|
/**
|
|
13
4
|
* Handle kb.query tool calls
|
|
14
5
|
* Reuses query logic from CLI command
|
|
@@ -16,75 +7,8 @@ export const VALID_ENTITY_TYPES = [
|
|
|
16
7
|
export async function handleKbQuery(prolog, args) {
|
|
17
8
|
const { type, id, tags, sourceFile, limit = 100, offset = 0 } = args;
|
|
18
9
|
try {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (type) {
|
|
22
|
-
if (!VALID_ENTITY_TYPES.includes(type)) {
|
|
23
|
-
throw new Error(`Invalid type '${type}'. Valid types: ${VALID_ENTITY_TYPES.join(", ")}. Use a single type value, or omit this parameter to query all entities.`);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
// Build Prolog query
|
|
27
|
-
let goal;
|
|
28
|
-
if (sourceFile) {
|
|
29
|
-
const safeSource = escapeAtomContent(sourceFile);
|
|
30
|
-
if (type) {
|
|
31
|
-
const safeType = escapeAtomContent(type);
|
|
32
|
-
goal = `findall([Id,'${safeType}',Props], (kb_entities_by_source('${safeSource}', SourceIds), member(Id, SourceIds), kb_entity(Id, '${safeType}', Props)), Results)`;
|
|
33
|
-
}
|
|
34
|
-
else {
|
|
35
|
-
goal = `findall([Id,Type,Props], (kb_entities_by_source('${safeSource}', SourceIds), member(Id, SourceIds), kb_entity(Id, Type, Props)), Results)`;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
else if (id && type) {
|
|
39
|
-
const safeId = escapeAtomContent(id);
|
|
40
|
-
const safeType = escapeAtomContent(type);
|
|
41
|
-
goal = `findall(['${safeId}','${safeType}',Props], kb_entity('${safeId}', '${safeType}', Props), Results)`;
|
|
42
|
-
}
|
|
43
|
-
else if (id) {
|
|
44
|
-
const safeId = escapeAtomContent(id);
|
|
45
|
-
goal = `findall(['${safeId}',Type,Props], kb_entity('${safeId}', Type, Props), Results)`;
|
|
46
|
-
}
|
|
47
|
-
else if (tags && tags.length > 0) {
|
|
48
|
-
// TODO: Reintroduce server-side (Prolog) tag filtering once normalization
|
|
49
|
-
// issues with tag list formats are resolved, to avoid fetching all entities
|
|
50
|
-
// before filtering in JS for large knowledge bases.
|
|
51
|
-
if (type) {
|
|
52
|
-
const safeType = escapeAtomContent(type);
|
|
53
|
-
goal = `findall([Id,'${safeType}',Props], kb_entity(Id, '${safeType}', Props), Results)`;
|
|
54
|
-
}
|
|
55
|
-
else {
|
|
56
|
-
goal = "findall([Id,Type,Props], kb_entity(Id, Type, Props), Results)";
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
else if (type) {
|
|
60
|
-
const safeType = escapeAtomContent(type);
|
|
61
|
-
goal = `findall([Id,'${safeType}',Props], kb_entity(Id, '${safeType}', Props), Results)`;
|
|
62
|
-
}
|
|
63
|
-
else {
|
|
64
|
-
goal = "findall([Id,Type,Props], kb_entity(Id, Type, Props), Results)";
|
|
65
|
-
}
|
|
66
|
-
const queryResult = await prolog.query(goal);
|
|
67
|
-
if (queryResult.success) {
|
|
68
|
-
if (queryResult.bindings.Results) {
|
|
69
|
-
const entitiesData = parseListOfLists(queryResult.bindings.Results);
|
|
70
|
-
for (const data of entitiesData) {
|
|
71
|
-
const entity = parseEntityFromList(data);
|
|
72
|
-
results.push(entity);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
else if (queryResult.bindings.Result) {
|
|
76
|
-
const entity = parseEntityFromBinding(queryResult.bindings.Result);
|
|
77
|
-
results = [entity];
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
else {
|
|
81
|
-
throw new Error(queryResult.error || "Query failed with unknown error");
|
|
82
|
-
}
|
|
83
|
-
if (tags && tags.length > 0) {
|
|
84
|
-
results = dedupeEntities(results.filter((entity) => hasAnyTag(entity, tags)));
|
|
85
|
-
}
|
|
86
|
-
// Apply pagination
|
|
87
|
-
const paginated = results.slice(offset, offset + limit);
|
|
10
|
+
const results = await loadEntities(prolog, { type, id, tags, sourceFile });
|
|
11
|
+
const paginated = paginateResults(results, limit, offset);
|
|
88
12
|
// Build human-readable text with entity IDs and titles
|
|
89
13
|
let text;
|
|
90
14
|
if (results.length === 0) {
|
|
@@ -120,34 +44,3 @@ export async function handleKbQuery(prolog, args) {
|
|
|
120
44
|
throw new Error(`Query execution failed: ${message}`);
|
|
121
45
|
}
|
|
122
46
|
}
|
|
123
|
-
function hasAnyTag(entity, requestedTags) {
|
|
124
|
-
const expected = new Set(requestedTags.map(normalizeTagValue));
|
|
125
|
-
const rawTags = entity.tags;
|
|
126
|
-
if (!Array.isArray(rawTags) || rawTags.length === 0) {
|
|
127
|
-
return false;
|
|
128
|
-
}
|
|
129
|
-
for (const tag of rawTags) {
|
|
130
|
-
if (expected.has(normalizeTagValue(tag))) {
|
|
131
|
-
return true;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
return false;
|
|
135
|
-
}
|
|
136
|
-
function normalizeTagValue(tag) {
|
|
137
|
-
return String(tag).trim();
|
|
138
|
-
}
|
|
139
|
-
function dedupeEntities(entities) {
|
|
140
|
-
const seen = new Set();
|
|
141
|
-
const deduped = [];
|
|
142
|
-
for (const entity of entities) {
|
|
143
|
-
const id = String(entity.id ?? "");
|
|
144
|
-
const type = String(entity.type ?? "");
|
|
145
|
-
const key = `${type}::${id}`;
|
|
146
|
-
if (seen.has(key)) {
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
149
|
-
seen.add(key);
|
|
150
|
-
deduped.push(entity);
|
|
151
|
-
}
|
|
152
|
-
return deduped;
|
|
153
|
-
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { rankEntities } from "kibi-cli/search-ranking";
|
|
2
|
+
import { loadEntities, paginateResults, validateEntityType, } from "./entity-query.js";
|
|
3
|
+
import { resolveWorkspaceRoot } from "../workspace.js";
|
|
4
|
+
// implements REQ-mcp-search-discovery, REQ-002
|
|
5
|
+
export async function handleKbSearch(prolog, args) {
|
|
6
|
+
const { query, type, limit = 20, offset = 0 } = args;
|
|
7
|
+
const trimmedQuery = query.trim();
|
|
8
|
+
if (!trimmedQuery) {
|
|
9
|
+
throw new Error("Search execution failed: query must be a non-empty string");
|
|
10
|
+
}
|
|
11
|
+
validateEntityType(type);
|
|
12
|
+
try {
|
|
13
|
+
const workspaceRoot = resolveWorkspaceRoot();
|
|
14
|
+
const entities = await loadEntities(prolog, { type });
|
|
15
|
+
const matches = await rankEntities(entities, trimmedQuery, workspaceRoot);
|
|
16
|
+
const paginated = paginateResults(matches, limit, offset);
|
|
17
|
+
const text = matches.length === 0
|
|
18
|
+
? `No search results for '${trimmedQuery}'.`
|
|
19
|
+
: `Found ${matches.length} search results for '${trimmedQuery}'. Showing ${paginated.length} (offset ${offset}, limit ${limit}): ${paginated
|
|
20
|
+
.map((match) => `${match.entity.id} [${match.reasons.join(", ")}]`)
|
|
21
|
+
.join(", ")}`;
|
|
22
|
+
return {
|
|
23
|
+
content: [{ type: "text", text }],
|
|
24
|
+
structuredContent: {
|
|
25
|
+
results: paginated,
|
|
26
|
+
count: matches.length,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
32
|
+
if (message.startsWith("Search execution failed:")) {
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
throw new Error(`Search execution failed: ${message}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { runJsonModuleQuery } from "./core-module.js";
|
|
2
|
+
// implements REQ-002, REQ-013
|
|
3
|
+
export async function handleKbStatus(prolog, _args) {
|
|
4
|
+
try {
|
|
5
|
+
const payload = await runJsonModuleQuery(prolog, "status.pl", "status:kb_status_json(JsonString)", "Status execution");
|
|
6
|
+
return {
|
|
7
|
+
content: [
|
|
8
|
+
{
|
|
9
|
+
type: "text",
|
|
10
|
+
text: `Branch ${payload.branch} is ${payload.syncState} (snapshot ${payload.snapshotId}, dirty=${payload.dirty})`,
|
|
11
|
+
},
|
|
12
|
+
],
|
|
13
|
+
structuredContent: payload,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
18
|
+
throw new Error(`Status execution failed: ${message}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
package/dist/tools-config.js
CHANGED
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
|
|
15
15
|
You should have received a copy of the GNU Affero General Public License
|
|
16
16
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
-
*/
|
|
18
|
-
|
|
17
|
+
*/
|
|
18
|
+
import { DIAGNOSTIC_MODE_ENABLED, DIAGNOSTIC_TELEMETRY_SCHEMA, } from "./diagnostics.js";
|
|
19
|
+
const BASE_TOOLS = [
|
|
19
20
|
// implements REQ-002
|
|
20
21
|
{
|
|
21
22
|
name: "kb_query",
|
|
@@ -63,6 +64,172 @@ export const TOOLS = [
|
|
|
63
64
|
},
|
|
64
65
|
},
|
|
65
66
|
},
|
|
67
|
+
{
|
|
68
|
+
name: "kb_search",
|
|
69
|
+
description: "Search KB entities for discovery using metadata and markdown body text. Use for exploratory lookup before exact follow-up with kb_query. No mutation side effects.",
|
|
70
|
+
inputSchema: {
|
|
71
|
+
type: "object",
|
|
72
|
+
required: ["query"],
|
|
73
|
+
properties: {
|
|
74
|
+
query: {
|
|
75
|
+
type: "string",
|
|
76
|
+
description: "Free-text query for metadata and markdown body discovery. Example: 'OAuth login flow'.",
|
|
77
|
+
},
|
|
78
|
+
type: {
|
|
79
|
+
type: "string",
|
|
80
|
+
enum: [
|
|
81
|
+
"req",
|
|
82
|
+
"scenario",
|
|
83
|
+
"test",
|
|
84
|
+
"adr",
|
|
85
|
+
"flag",
|
|
86
|
+
"event",
|
|
87
|
+
"symbol",
|
|
88
|
+
"fact",
|
|
89
|
+
],
|
|
90
|
+
description: "Optional entity type filter to narrow discovery. Example: 'req'.",
|
|
91
|
+
},
|
|
92
|
+
limit: {
|
|
93
|
+
type: "integer",
|
|
94
|
+
default: 20,
|
|
95
|
+
description: "Optional max rows to return after ranking. Default: 20.",
|
|
96
|
+
},
|
|
97
|
+
offset: {
|
|
98
|
+
type: "integer",
|
|
99
|
+
default: 0,
|
|
100
|
+
description: "Optional zero-based pagination offset. Default: 0.",
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: "kb_status",
|
|
107
|
+
description: "Report current branch, snapshot, and freshness metadata for the attached KB. Read-only status inspection with no mutation side effects.",
|
|
108
|
+
inputSchema: {
|
|
109
|
+
type: "object",
|
|
110
|
+
properties: {},
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: "kb_find_gaps",
|
|
115
|
+
description: "Run bulk missing/present relationship analysis over KB entities. Use for questions like which requirements lack scenarios or tests. No mutation side effects.",
|
|
116
|
+
inputSchema: {
|
|
117
|
+
type: "object",
|
|
118
|
+
properties: {
|
|
119
|
+
type: {
|
|
120
|
+
type: "string",
|
|
121
|
+
enum: [
|
|
122
|
+
"req",
|
|
123
|
+
"scenario",
|
|
124
|
+
"test",
|
|
125
|
+
"adr",
|
|
126
|
+
"flag",
|
|
127
|
+
"event",
|
|
128
|
+
"symbol",
|
|
129
|
+
"fact",
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
missingRelationships: {
|
|
133
|
+
type: "array",
|
|
134
|
+
items: { type: "string" },
|
|
135
|
+
},
|
|
136
|
+
presentRelationships: {
|
|
137
|
+
type: "array",
|
|
138
|
+
items: { type: "string" },
|
|
139
|
+
},
|
|
140
|
+
tags: {
|
|
141
|
+
type: "array",
|
|
142
|
+
items: { type: "string" },
|
|
143
|
+
},
|
|
144
|
+
sourceFile: {
|
|
145
|
+
type: "string",
|
|
146
|
+
},
|
|
147
|
+
limit: {
|
|
148
|
+
type: "integer",
|
|
149
|
+
default: 100,
|
|
150
|
+
},
|
|
151
|
+
offset: {
|
|
152
|
+
type: "integer",
|
|
153
|
+
default: 0,
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
name: "kb_coverage",
|
|
160
|
+
description: "Generate curated coverage reports for requirements, symbols, or grouped types. Read-only reporting with no mutation side effects.",
|
|
161
|
+
inputSchema: {
|
|
162
|
+
type: "object",
|
|
163
|
+
properties: {
|
|
164
|
+
by: {
|
|
165
|
+
type: "string",
|
|
166
|
+
enum: ["req", "symbol", "type"],
|
|
167
|
+
default: "req",
|
|
168
|
+
},
|
|
169
|
+
tags: {
|
|
170
|
+
type: "array",
|
|
171
|
+
items: { type: "string" },
|
|
172
|
+
},
|
|
173
|
+
includePassing: {
|
|
174
|
+
type: "boolean",
|
|
175
|
+
default: false,
|
|
176
|
+
},
|
|
177
|
+
includeTransitive: {
|
|
178
|
+
type: "boolean",
|
|
179
|
+
default: true,
|
|
180
|
+
},
|
|
181
|
+
limit: {
|
|
182
|
+
type: "integer",
|
|
183
|
+
default: 100,
|
|
184
|
+
},
|
|
185
|
+
offset: {
|
|
186
|
+
type: "integer",
|
|
187
|
+
default: 0,
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
name: "kb_graph",
|
|
194
|
+
description: "Run bounded graph traversal from one or more seed IDs across curated relationship types. No mutation side effects.",
|
|
195
|
+
inputSchema: {
|
|
196
|
+
type: "object",
|
|
197
|
+
required: ["seedIds"],
|
|
198
|
+
properties: {
|
|
199
|
+
seedIds: {
|
|
200
|
+
type: "array",
|
|
201
|
+
items: { type: "string" },
|
|
202
|
+
},
|
|
203
|
+
relationships: {
|
|
204
|
+
type: "array",
|
|
205
|
+
items: { type: "string" },
|
|
206
|
+
},
|
|
207
|
+
direction: {
|
|
208
|
+
type: "string",
|
|
209
|
+
enum: ["outgoing", "incoming", "both"],
|
|
210
|
+
default: "outgoing",
|
|
211
|
+
},
|
|
212
|
+
depth: {
|
|
213
|
+
type: "integer",
|
|
214
|
+
default: 1,
|
|
215
|
+
minimum: 1,
|
|
216
|
+
maximum: 5,
|
|
217
|
+
},
|
|
218
|
+
entityTypes: {
|
|
219
|
+
type: "array",
|
|
220
|
+
items: { type: "string" },
|
|
221
|
+
},
|
|
222
|
+
maxNodes: {
|
|
223
|
+
type: "integer",
|
|
224
|
+
default: 200,
|
|
225
|
+
},
|
|
226
|
+
maxEdges: {
|
|
227
|
+
type: "integer",
|
|
228
|
+
default: 500,
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
},
|
|
66
233
|
{
|
|
67
234
|
name: "kb_upsert",
|
|
68
235
|
description: "Create or update one entity and optional relationships. Use for KB mutations after validating intent. Use the `relationships` array for batch creation of multiple links in a single call (e.g., linking a requirement to multiple tests or facts). Prefer modeling requirements as reusable fact links (`constrains`, `requires_property`) so consistency and contradiction checks remain queryable. Relationship endpoints must already exist in KB. Do not use for read-only inspection. Side effects: writes KB, may refresh symbol coordinates.",
|
|
@@ -216,3 +383,31 @@ export const TOOLS = [
|
|
|
216
383
|
},
|
|
217
384
|
},
|
|
218
385
|
];
|
|
386
|
+
/**
|
|
387
|
+
* Inject _diagnostic_telemetry schema into tool inputs when diagnostic mode is enabled.
|
|
388
|
+
*/
|
|
389
|
+
function withDiagnosticTelemetrySchema(tools) {
|
|
390
|
+
return tools.map((tool) => {
|
|
391
|
+
const schema = tool.inputSchema;
|
|
392
|
+
const properties = schema.properties && typeof schema.properties === "object"
|
|
393
|
+
? schema.properties
|
|
394
|
+
: {};
|
|
395
|
+
return {
|
|
396
|
+
...tool,
|
|
397
|
+
inputSchema: {
|
|
398
|
+
...schema,
|
|
399
|
+
properties: {
|
|
400
|
+
...properties,
|
|
401
|
+
_diagnostic_telemetry: DIAGNOSTIC_TELEMETRY_SCHEMA,
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Active tools list.
|
|
409
|
+
* In diagnostic mode, all tools include the _diagnostic_telemetry parameter.
|
|
410
|
+
*/
|
|
411
|
+
export const TOOLS = DIAGNOSTIC_MODE_ENABLED
|
|
412
|
+
? withDiagnosticTelemetrySchema(BASE_TOOLS)
|
|
413
|
+
: BASE_TOOLS;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kibi-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"dependencies": {
|
|
5
5
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
6
6
|
"ajv": "^8.18.0",
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
"fast-glob": "^3.2.12",
|
|
10
10
|
"gray-matter": "^4.0.3",
|
|
11
11
|
"js-yaml": "^4.1.0",
|
|
12
|
-
"kibi-cli": "^0.
|
|
13
|
-
"kibi-core": "^0.
|
|
12
|
+
"kibi-cli": "^0.3.0",
|
|
13
|
+
"kibi-core": "^0.2.0",
|
|
14
14
|
"mcpcat": "^0.1.12",
|
|
15
15
|
"ts-morph": "^23.0.0",
|
|
16
16
|
"zod": "^4.3.6"
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@types/bun": "latest",
|
|
43
|
-
"@types/node": "latest"
|
|
43
|
+
"@types/node": "latest",
|
|
44
|
+
"typescript": "^5.7.0"
|
|
44
45
|
},
|
|
45
46
|
"license": "AGPL-3.0-or-later",
|
|
46
47
|
"author": "Piotr Franczyk",
|