kibi-mcp 0.3.3 → 0.5.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.
@@ -1,14 +1,5 @@
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
- ];
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,73 +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
- let results = [];
20
- // Validate type if provided
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
- // JS-side fallback until REQ-mcp-tag-filtering-server-side is implemented.
49
- if (type) {
50
- const safeType = escapeAtomContent(type);
51
- goal = `findall([Id,'${safeType}',Props], kb_entity(Id, '${safeType}', Props), Results)`;
52
- }
53
- else {
54
- goal = "findall([Id,Type,Props], kb_entity(Id, Type, Props), Results)";
55
- }
56
- }
57
- else if (type) {
58
- const safeType = escapeAtomContent(type);
59
- goal = `findall([Id,'${safeType}',Props], kb_entity(Id, '${safeType}', Props), Results)`;
60
- }
61
- else {
62
- goal = "findall([Id,Type,Props], kb_entity(Id, Type, Props), Results)";
63
- }
64
- const queryResult = await prolog.query(goal);
65
- if (queryResult.success) {
66
- if (queryResult.bindings.Results) {
67
- const entitiesData = parseListOfLists(queryResult.bindings.Results);
68
- for (const data of entitiesData) {
69
- const entity = parseEntityFromList(data);
70
- results.push(entity);
71
- }
72
- }
73
- else if (queryResult.bindings.Result) {
74
- const entity = parseEntityFromBinding(queryResult.bindings.Result);
75
- results = [entity];
76
- }
77
- }
78
- else {
79
- throw new Error(queryResult.error || "Query failed with unknown error");
80
- }
81
- if (tags && tags.length > 0) {
82
- results = dedupeEntities(results.filter((entity) => hasAnyTag(entity, tags)));
83
- }
84
- // Apply pagination
85
- const paginated = results.slice(offset, offset + limit);
10
+ const results = await loadEntities(prolog, { type, id, tags, sourceFile });
11
+ const paginated = paginateResults(results, limit, offset);
86
12
  // Build human-readable text with entity IDs and titles
87
13
  let text;
88
14
  if (results.length === 0) {
@@ -118,34 +44,3 @@ export async function handleKbQuery(prolog, args) {
118
44
  throw new Error(`Query execution failed: ${message}`);
119
45
  }
120
46
  }
121
- function hasAnyTag(entity, requestedTags) {
122
- const expected = new Set(requestedTags.map(normalizeTagValue));
123
- const rawTags = entity.tags;
124
- if (!Array.isArray(rawTags) || rawTags.length === 0) {
125
- return false;
126
- }
127
- for (const tag of rawTags) {
128
- if (expected.has(normalizeTagValue(tag))) {
129
- return true;
130
- }
131
- }
132
- return false;
133
- }
134
- function normalizeTagValue(tag) {
135
- return String(tag).trim();
136
- }
137
- function dedupeEntities(entities) {
138
- const seen = new Set();
139
- const deduped = [];
140
- for (const entity of entities) {
141
- const id = String(entity.id ?? "");
142
- const type = String(entity.type ?? "");
143
- const key = `${type}::${id}`;
144
- if (seen.has(key)) {
145
- continue;
146
- }
147
- seen.add(key);
148
- deduped.push(entity);
149
- }
150
- return deduped;
151
- }
@@ -0,0 +1,37 @@
1
+ import { rankEntities } from "kibi-cli/search-ranking";
2
+ import { resolveWorkspaceRoot } from "../workspace.js";
3
+ import { loadEntities, paginateResults, validateEntityType, } from "./entity-query.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
+ }
@@ -151,6 +151,7 @@ export async function refreshCoordinatesForSymbolId(symbolId, workspaceRoot = re
151
151
  return { refreshed, found: true };
152
152
  }
153
153
  export function resolveManifestPath(workspaceRoot) {
154
+ // implements REQ-002, REQ-013
154
155
  const configPath = path.join(workspaceRoot, ".kb", "config.json");
155
156
  if (existsSync(configPath)) {
156
157
  try {
@@ -168,7 +169,9 @@ export function resolveManifestPath(workspaceRoot) {
168
169
  : path.resolve(workspaceRoot, config.symbolsManifest);
169
170
  }
170
171
  }
171
- catch { }
172
+ catch {
173
+ // config file missing or malformed; fall through to defaults
174
+ }
172
175
  }
173
176
  const candidates = [
174
177
  path.join(workspaceRoot, "symbols.yaml"),
@@ -16,7 +16,7 @@
16
16
  along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
  */
18
18
  import Ajv from "ajv";
19
- import { escapeAtom, toPrologAtom } from "kibi-cli/prolog/codec";
19
+ import { escapeAtom, toPrologAtom, toPrologString, } from "kibi-cli/prolog/codec";
20
20
  import entitySchema from "kibi-cli/schemas/entity";
21
21
  import relationshipSchema from "kibi-cli/schemas/relationship";
22
22
  import { refreshCoordinatesForSymbolId } from "./symbols.js";
@@ -73,6 +73,10 @@ export async function handleKbUpsert(prolog, args) {
73
73
  throw new Error(`Relationship validation failed at index ${i}: ${errorMessages}`);
74
74
  }
75
75
  }
76
+ validateRelationshipSources(id, relationships);
77
+ // Validate strict-lane fact_kind pairing for constrains/requires_property
78
+ // implements REQ-011
79
+ await validateStrictLanePairing(prolog, relationships);
76
80
  let created = 0;
77
81
  let updated = 0;
78
82
  let relationshipsCreated = 0;
@@ -94,26 +98,44 @@ export async function handleKbUpsert(prolog, args) {
94
98
  const from = rel.from;
95
99
  const to = rel.to;
96
100
  const metadata = buildRelationshipMetadata(rel);
97
- relationshipGoals.push(`kb_assert_relationship(${relType}, '${escapeAtom(from)}', '${escapeAtom(to)}', ${metadata})`);
101
+ relationshipGoals.push(`kb_assert_relationship_no_audit(${relType}, '${escapeAtom(from)}', '${escapeAtom(to)}', ${metadata})`);
98
102
  }
99
103
  // Build atomic transaction goal wrapping entity + all relationships
104
+ // For requirements, also include contradiction check within the transaction
100
105
  // implements REQ-002, REQ-011
101
106
  let transactionGoal;
107
+ const needsContradictionCheck = type === "req" && !args._skipContradictionCheck;
102
108
  if (relationshipGoals.length === 0) {
103
109
  // Simple case: just entity
104
- transactionGoal = `rdf_transaction((kb_assert_entity(${type}, ${props})))`;
110
+ if (needsContradictionCheck) {
111
+ transactionGoal = `rdf_transaction((kb_assert_entity_no_audit(${type}, ${props}), check_req_contradiction('${escapeAtom(id)}')))`;
112
+ }
113
+ else {
114
+ transactionGoal = `rdf_transaction((kb_assert_entity_no_audit(${type}, ${props})))`;
115
+ }
105
116
  }
106
117
  else {
107
118
  // Entity + relationships in one transaction
108
119
  const goals = [
109
- `kb_assert_entity(${type}, ${props})`,
120
+ `kb_assert_entity_no_audit(${type}, ${props})`,
110
121
  ...relationshipGoals,
111
122
  ].join(", ");
112
- transactionGoal = `rdf_transaction((${goals}))`;
123
+ if (needsContradictionCheck) {
124
+ transactionGoal = `rdf_transaction((${goals}, check_req_contradiction('${escapeAtom(id)}')))`;
125
+ }
126
+ else {
127
+ transactionGoal = `rdf_transaction((${goals}))`;
128
+ }
113
129
  }
114
130
  const txResult = await prolog.query(transactionGoal);
115
131
  if (!txResult.success) {
116
- throw new Error(`Failed to upsert entity ${id}: ${txResult.error || "Unknown error"} (goal: ${transactionGoal})`);
132
+ // Format error message without exposing raw transaction goal
133
+ const formattedError = formatUpsertError(id, txResult.error);
134
+ throw new Error(formattedError);
135
+ }
136
+ await recordEntityAudit(prolog, type, entity);
137
+ for (const rel of relationships) {
138
+ await recordRelationshipAudit(prolog, rel);
117
139
  }
118
140
  // Update counters
119
141
  if (isUpdate) {
@@ -131,10 +153,6 @@ export async function handleKbUpsert(prolog, args) {
131
153
  if (!saveResult.success) {
132
154
  throw new Error(`Failed to save KB after upsert: ${saveResult.error || "Unknown error"}`);
133
155
  }
134
- let contradictionPairsDetected;
135
- if (type === "req" && !args._skipContradictionCheck) {
136
- contradictionPairsDetected = await detectContradictionPairs(prolog, id);
137
- }
138
156
  if (type === "symbol") {
139
157
  try {
140
158
  await refreshCoordinatesForSymbolId(id);
@@ -150,16 +168,13 @@ export async function handleKbUpsert(prolog, args) {
150
168
  content: [
151
169
  {
152
170
  type: "text",
153
- text: contradictionPairsDetected && contradictionPairsDetected > 0
154
- ? `Upserted ${id} (${created > 0 ? "created" : "updated"}) with ${relationshipsCreated} relationship(s). Contradiction probe detected ${contradictionPairsDetected} potential conflict pair(s).`
155
- : `Upserted ${id} (${created > 0 ? "created" : "updated"}) with ${relationshipsCreated} relationship(s).`,
171
+ text: `Upserted ${id} (${created > 0 ? "created" : "updated"}) with ${relationshipsCreated} relationship(s).`,
156
172
  },
157
173
  ],
158
174
  structuredContent: {
159
175
  created,
160
176
  updated,
161
177
  relationships_created: relationshipsCreated,
162
- contradiction_pairs_detected: contradictionPairsDetected,
163
178
  },
164
179
  };
165
180
  }
@@ -168,27 +183,28 @@ export async function handleKbUpsert(prolog, args) {
168
183
  throw new Error(`Upsert execution failed: ${message}`);
169
184
  }
170
185
  }
171
- async function detectContradictionPairs(prolog, reqId) {
172
- const escaped = escapeAtom(reqId);
173
- const goal = `aggregate_all(count, (contradicting_reqs(A, B, _), (A = '${escaped}' ; B = '${escaped}' ; A = 'file:///${escaped}' ; B = 'file:///${escaped}')), Count)`;
174
- const result = await prolog.query(goal);
175
- if (!result.success) {
176
- return 0;
177
- }
178
- const raw = result.bindings.Count;
179
- const count = Number(raw);
180
- return Number.isFinite(count) ? count : 0;
181
- }
182
186
  /**
183
187
  * Build Prolog property list from entity object
184
188
  * Returns simple Key=Value format without typed literals
185
189
  * Example output: "[id='test-1', title=\"Test\", status=active]"
190
+ * implements REQ-002
186
191
  */
187
192
  function buildPropertyList(entity) {
188
193
  const pairs = [];
189
194
  // Defined internally to ensure thread safety and avoid initialization order issues.
190
195
  // Using simple arrays instead of Sets is performant enough for small lists and avoids Set allocation overhead.
191
- const ATOM_FIELDS = ["status", "owner", "priority", "severity"];
196
+ // implements REQ-002
197
+ const ATOM_FIELDS = [
198
+ "status",
199
+ "owner",
200
+ "priority",
201
+ "severity",
202
+ // Typed fact enum fields must be atoms for Prolog validation
203
+ "fact_kind",
204
+ "operator",
205
+ "value_type",
206
+ "polarity",
207
+ ];
192
208
  const STRING_FIELDS = [
193
209
  "id",
194
210
  "title",
@@ -211,10 +227,10 @@ function buildPropertyList(entity) {
211
227
  prologValue = toPrologAtom(value);
212
228
  }
213
229
  else if (STRING_FIELDS.includes(key) && typeof value === "string") {
214
- prologValue = `"${escapeQuotes(value)}"`;
230
+ prologValue = `${toPrologString(value)}`;
215
231
  }
216
232
  else if (typeof value === "string") {
217
- prologValue = `"${escapeQuotes(value)}"`;
233
+ prologValue = `${toPrologString(value)}`;
218
234
  }
219
235
  else if (typeof value === "number") {
220
236
  prologValue = String(value);
@@ -223,7 +239,7 @@ function buildPropertyList(entity) {
223
239
  prologValue = value ? "true" : "false";
224
240
  }
225
241
  else {
226
- prologValue = `"${escapeQuotes(String(value))}"`;
242
+ prologValue = `${toPrologString(String(value))}`;
227
243
  }
228
244
  pairs.push(`${key}=${prologValue}`);
229
245
  }
@@ -240,21 +256,122 @@ function buildRelationshipMetadata(rel) {
240
256
  continue;
241
257
  let prologValue;
242
258
  if (typeof value === "string") {
243
- prologValue = `"${escapeQuotes(value)}"`;
259
+ prologValue = `${toPrologString(value)}`;
244
260
  }
245
261
  else if (typeof value === "number") {
246
262
  prologValue = String(value);
247
263
  }
248
264
  else {
249
- prologValue = `"${escapeQuotes(String(value))}"`;
265
+ prologValue = `${toPrologString(String(value))}`;
250
266
  }
251
267
  pairs.push(`${key}=${prologValue}`);
252
268
  }
253
269
  return `[${pairs.join(", ")}]`;
254
270
  }
255
271
  /**
256
- * Escape double quotes in strings for Prolog
272
+ * Ensure all relationship rows belong to the entity being upserted.
273
+ * Rejects foreign-source relationship writes in the same request.
274
+ * implements REQ-002, REQ-011
275
+ */
276
+ function validateRelationshipSources(entityId, relationships) {
277
+ for (const rel of relationships) {
278
+ if (rel.from !== entityId) {
279
+ throw new Error(`Relationship source must match the upserted entity ${entityId}; received from=${String(rel.from)}`);
280
+ }
281
+ }
282
+ }
283
+ /**
284
+ * Validate strict-lane fact_kind pairing for constrains/requires_property relationships.
285
+ * constrains targets must be subject, observation, or meta facts (or legacy without fact_kind).
286
+ * requires_property targets must be property_value, observation, or meta facts (or legacy without fact_kind).
287
+ * implements REQ-011
288
+ */
289
+ async function validateStrictLanePairing(prolog, relationships) {
290
+ // implements REQ-011
291
+ for (const rel of relationships) {
292
+ if (rel.type === "constrains") {
293
+ const targetId = rel.to;
294
+ // Reject if target is a fact with fact_kind=property_value.
295
+ // Allow: legacy (no fact_kind), subject, observation, meta, or non-existent
296
+ // (non-existent targets are caught by relationship type validation elsewhere).
297
+ const rejectResult = await prolog.query(`once((kb_entity('${escapeAtom(targetId)}', fact, _SlpProps), memberchk(fact_kind=_SlpFK, _SlpProps), normalize_term_atom(_SlpFK, property_value)))`);
298
+ if (rejectResult.success) {
299
+ throw new Error(`Relationship 'constrains' requires target '${targetId}' to be a subject, observation, or meta fact. Property_value facts cannot be direct targets of constrains relationships.`);
300
+ }
301
+ }
302
+ else if (rel.type === "requires_property") {
303
+ const targetId = rel.to;
304
+ // Reject if target is a fact with fact_kind=subject.
305
+ // Allow: legacy (no fact_kind), property_value, observation, meta, or non-existent.
306
+ const rejectResult = await prolog.query(`once((kb_entity('${escapeAtom(targetId)}', fact, _SlpProps), memberchk(fact_kind=_SlpFK, _SlpProps), normalize_term_atom(_SlpFK, subject)))`);
307
+ if (rejectResult.success) {
308
+ throw new Error(`Relationship 'requires_property' requires target '${targetId}' to be a property_value, observation, or meta fact. Subject facts cannot be direct targets of requires_property relationships.`);
309
+ }
310
+ }
311
+ }
312
+ }
313
+ /**
314
+ * Record audit entry for a successfully committed entity mutation.
315
+ * Called only after the RDF transaction succeeds.
316
+ * implements REQ-011
317
+ */
318
+ async function recordEntityAudit(prolog, type, entity) {
319
+ const props = buildPropertyList(entity);
320
+ const result = await prolog.query(`kb_log_entity_upsert(${type}, ${props})`);
321
+ if (!result.success) {
322
+ throw new Error(`Failed to record audit entry for ${String(entity.id)}: ${result.error || "Unknown error"}`);
323
+ }
324
+ }
325
+ /**
326
+ * Record audit entry for a successfully committed relationship mutation.
327
+ * Called only after the RDF transaction succeeds.
328
+ * implements REQ-011
257
329
  */
258
- function escapeQuotes(str) {
259
- return str.replace(/"/g, '\\"');
330
+ async function recordRelationshipAudit(prolog, rel) {
331
+ const relType = rel.type;
332
+ const from = rel.from;
333
+ const to = rel.to;
334
+ const metadata = buildRelationshipMetadata(rel);
335
+ const result = await prolog.query(`kb_log_relationship_upsert(${relType}, '${escapeAtom(from)}', '${escapeAtom(to)}', ${metadata})`);
336
+ if (!result.success) {
337
+ throw new Error(`Failed to record relationship audit entry ${from}->${to}: ${result.error || "Unknown error"}`);
338
+ }
339
+ }
340
+ /**
341
+ * Format upsert error message for user-facing display.
342
+ * Removes raw transaction goals and extracts meaningful contradiction details.
343
+ * implements REQ-011
344
+ */
345
+ function formatUpsertError(entityId, rawError) {
346
+ if (!rawError) {
347
+ return `Failed to upsert entity ${entityId}: Unknown error`;
348
+ }
349
+ // Check for contradiction error - Prolog returns kb_contradiction([...]) term
350
+ // Try to extract readable details from the term
351
+ const contradictionMatch = rawError.match(/kb_contradiction\(\s*\[([^\]]+)\]\s*\)/);
352
+ if (contradictionMatch) {
353
+ // Extract individual conflict details from the list
354
+ const details = contradictionMatch[1];
355
+ // Parse out readable parts - each entry is like 'Reason'-'ReqId'
356
+ const conflicts = [];
357
+ const conflictRegex = /'([^']+)'-'([^']+)'/g;
358
+ let execResult = conflictRegex.exec(details);
359
+ while (execResult !== null) {
360
+ const reason = execResult[1];
361
+ const otherReq = execResult[2];
362
+ conflicts.push(` - Conflicts with ${otherReq}: ${reason}`);
363
+ execResult = conflictRegex.exec(details);
364
+ }
365
+ if (conflicts.length > 0) {
366
+ const uniqueConflicts = [...new Set(conflicts)];
367
+ return `Contradiction detected for requirement ${entityId}:\n${uniqueConflicts.join("\n")}\n\nTo resolve:\n 1. Add a supersedes relationship from the new requirement to the conflicting one, OR\n 2. Deprecate the conflicting requirement before creating the new one.`;
368
+ }
369
+ return `Contradiction detected for entity ${entityId}: This requirement conflicts with existing requirements. Add a supersedes relationship to the conflicting requirement, or deprecate the old requirement before creating the new one.`;
370
+ }
371
+ // Check for RDF transaction error
372
+ if (rawError.includes("rdf_transaction")) {
373
+ return `Failed to upsert entity ${entityId}: Transaction failed`;
374
+ }
375
+ // Default: return cleaned error without raw goal
376
+ return `Failed to upsert entity ${entityId}: ${rawError}`;
260
377
  }