kibi-mcp 0.2.3 → 0.3.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,27 +1,4 @@
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
- /**
19
- * Escape a string for embedding inside a single-quoted Prolog atom.
20
- * Doubles single-quote characters per ISO Prolog standard.
21
- */
22
- function escapeAtomContent(value) {
23
- return value.replace(/'/g, "''");
24
- }
1
+ import { escapeAtomContent, parseEntityFromBinding, parseEntityFromList, parseListOfLists, } from "kibi-cli/prolog/codec";
25
2
  export const VALID_ENTITY_TYPES = [
26
3
  "req",
27
4
  "scenario",
@@ -68,13 +45,15 @@ export async function handleKbQuery(prolog, args) {
68
45
  goal = `findall(['${safeId}',Type,Props], kb_entity('${safeId}', Type, Props), Results)`;
69
46
  }
70
47
  else if (tags && tags.length > 0) {
71
- const tagList = `[${tags.map((t) => `'${escapeAtomContent(t)}'`).join(",")}]`;
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.
72
51
  if (type) {
73
52
  const safeType = escapeAtomContent(type);
74
- goal = `findall([Id,'${safeType}',Props], (kb_entity(Id, '${safeType}', Props), memberchk(tags=Tags, Props), member(Tag, Tags), member(Tag, ${tagList})), Results)`;
53
+ goal = `findall([Id,'${safeType}',Props], kb_entity(Id, '${safeType}', Props), Results)`;
75
54
  }
76
55
  else {
77
- goal = `findall([Id,Type,Props], (kb_entity(Id, Type, Props), memberchk(tags=Tags, Props), member(Tag, Tags), member(Tag, ${tagList})), Results)`;
56
+ goal = "findall([Id,Type,Props], kb_entity(Id, Type, Props), Results)";
78
57
  }
79
58
  }
80
59
  else if (type) {
@@ -101,6 +80,9 @@ export async function handleKbQuery(prolog, args) {
101
80
  else {
102
81
  throw new Error(queryResult.error || "Query failed with unknown error");
103
82
  }
83
+ if (tags && tags.length > 0) {
84
+ results = dedupeEntities(results.filter((entity) => hasAnyTag(entity, tags)));
85
+ }
104
86
  // Apply pagination
105
87
  const paginated = results.slice(offset, offset + limit);
106
88
  // Build human-readable text with entity IDs and titles
@@ -138,234 +120,34 @@ export async function handleKbQuery(prolog, args) {
138
120
  throw new Error(`Query execution failed: ${message}`);
139
121
  }
140
122
  }
141
- /**
142
- * Parse a Prolog list of lists into a JavaScript array.
143
- * Input: "[[a,b,c],[d,e,f]]"
144
- * Output: [["a", "b", "c"], ["d", "e", "f"]]
145
- */
146
- export function parseListOfLists(listStr) {
147
- const cleaned = listStr.trim().replace(/^\[/, "").replace(/\]$/, "");
148
- if (cleaned === "") {
149
- return [];
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;
150
128
  }
151
- const results = [];
152
- let depth = 0;
153
- let current = "";
154
- let currentList = [];
155
- for (let i = 0; i < cleaned.length; i++) {
156
- const char = cleaned[i];
157
- if (char === "[") {
158
- depth++;
159
- if (depth > 1)
160
- current += char;
161
- }
162
- else if (char === "]") {
163
- depth--;
164
- if (depth === 0) {
165
- if (current) {
166
- currentList.push(current.trim());
167
- current = "";
168
- }
169
- if (currentList.length > 0) {
170
- results.push(currentList);
171
- currentList = [];
172
- }
173
- }
174
- else {
175
- current += char;
176
- }
177
- }
178
- else if (char === "," && depth === 1) {
179
- if (current) {
180
- currentList.push(current.trim());
181
- current = "";
182
- }
183
- }
184
- else if (char === "," && depth === 0) {
185
- // Skip comma between lists
186
- }
187
- else {
188
- current += char;
129
+ for (const tag of rawTags) {
130
+ if (expected.has(normalizeTagValue(tag))) {
131
+ return true;
189
132
  }
190
133
  }
191
- return results;
192
- }
193
- /**
194
- * Parse a single entity from Prolog binding format.
195
- * Input: "[abc123, req, [id=abc123, title=\"Test\", ...]]"
196
- */
197
- export function parseEntityFromBinding(bindingStr) {
198
- const cleaned = bindingStr.trim().replace(/^\[/, "").replace(/\]$/, "");
199
- const parts = splitTopLevel(cleaned, ",");
200
- if (parts.length < 3) {
201
- return {};
202
- }
203
- const id = parts[0].trim();
204
- const type = parts[1].trim();
205
- const propsStr = parts.slice(2).join(",").trim();
206
- const props = parsePropertyList(propsStr);
207
- return { ...props, id: normalizeEntityId(stripOuterQuotes(id)), type };
134
+ return false;
208
135
  }
209
- /**
210
- * Parse entity from array returned by parseListOfLists.
211
- * Input: ["abc123", "req", "[id=abc123, title=\"Test\", ...]"]
212
- */
213
- export function parseEntityFromList(data) {
214
- if (data.length < 3) {
215
- return {};
216
- }
217
- const id = data[0].trim();
218
- const type = data[1].trim();
219
- const propsStr = data[2].trim();
220
- const props = parsePropertyList(propsStr);
221
- return { ...props, id: normalizeEntityId(stripOuterQuotes(id)), type };
136
+ function normalizeTagValue(tag) {
137
+ return String(tag).trim();
222
138
  }
223
- /**
224
- * Parse Prolog property list into JavaScript object.
225
- */
226
- export function parsePropertyList(propsStr) {
227
- const props = {};
228
- let cleaned = propsStr.trim();
229
- if (cleaned.startsWith("[")) {
230
- cleaned = cleaned.substring(1);
231
- }
232
- if (cleaned.endsWith("]")) {
233
- cleaned = cleaned.substring(0, cleaned.length - 1);
234
- }
235
- const pairs = splitTopLevel(cleaned, ",");
236
- for (const pair of pairs) {
237
- const eqIndex = pair.indexOf("=");
238
- if (eqIndex === -1)
239
- continue;
240
- const key = pair.substring(0, eqIndex).trim();
241
- const value = pair.substring(eqIndex + 1).trim();
242
- if (key === "..." || value === "..." || value === "...|...") {
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)) {
243
147
  continue;
244
148
  }
245
- const parsed = parsePrologValue(value);
246
- props[key] = parsed;
247
- }
248
- return props;
249
- }
250
- /**
251
- * Parse a single Prolog value, handling typed literals and URIs.
252
- */
253
- export function parsePrologValue(valueInput) {
254
- const value = valueInput.trim();
255
- // Handle typed literal: ^^("value", type)
256
- if (value.startsWith("^^(")) {
257
- const innerStart = value.indexOf("(") + 1;
258
- let depth = 1;
259
- let innerEnd = innerStart;
260
- for (let i = innerStart; i < value.length; i++) {
261
- if (value[i] === "(")
262
- depth++;
263
- if (value[i] === ")") {
264
- depth--;
265
- if (depth === 0) {
266
- innerEnd = i;
267
- break;
268
- }
269
- }
270
- }
271
- const innerContent = value.substring(innerStart, innerEnd);
272
- const parts = splitTopLevel(innerContent, ",");
273
- if (parts.length >= 2) {
274
- let literalValue = parts[0].trim();
275
- if (literalValue.startsWith('"') && literalValue.endsWith('"')) {
276
- literalValue = literalValue.substring(1, literalValue.length - 1);
277
- }
278
- // Handle array notation
279
- if (literalValue.startsWith("[") && literalValue.endsWith("]")) {
280
- const listContent = literalValue.substring(1, literalValue.length - 1);
281
- if (listContent === "") {
282
- return [];
283
- }
284
- return splitTopLevel(listContent, ",").map((item) => item.trim());
285
- }
286
- return literalValue;
287
- }
288
- }
289
- // Handle URI
290
- if (value.startsWith("file:///")) {
291
- const lastSlash = value.lastIndexOf("/");
292
- if (lastSlash !== -1) {
293
- return value.substring(lastSlash + 1);
294
- }
295
- return value;
296
- }
297
- // Handle quoted string
298
- if (value.startsWith('"') && value.endsWith('"')) {
299
- return value.substring(1, value.length - 1);
300
- }
301
- // Handle quoted atom
302
- if (value.startsWith("'") && value.endsWith("'")) {
303
- return value.substring(1, value.length - 1);
304
- }
305
- // Handle list
306
- if (value.startsWith("[") && value.endsWith("]")) {
307
- const listContent = value.substring(1, value.length - 1);
308
- if (listContent === "") {
309
- return [];
310
- }
311
- const items = splitTopLevel(listContent, ",").map((item) => {
312
- return parsePrologValue(item.trim());
313
- });
314
- return items;
315
- }
316
- return value;
317
- }
318
- /**
319
- * Split a string by delimiter at the top level (not inside brackets or quotes).
320
- */
321
- export function splitTopLevel(str, delimiter) {
322
- const results = [];
323
- let current = "";
324
- let depth = 0;
325
- let inQuotes = false;
326
- for (let i = 0; i < str.length; i++) {
327
- const char = str[i];
328
- const prevChar = i > 0 ? str[i - 1] : "";
329
- if (char === '"' && prevChar !== "\\") {
330
- inQuotes = !inQuotes;
331
- current += char;
332
- }
333
- else if (!inQuotes && (char === "[" || char === "(")) {
334
- depth++;
335
- current += char;
336
- }
337
- else if (!inQuotes && (char === "]" || char === ")")) {
338
- depth--;
339
- current += char;
340
- }
341
- else if (!inQuotes && depth === 0 && char === delimiter) {
342
- if (current) {
343
- results.push(current);
344
- current = "";
345
- }
346
- }
347
- else {
348
- current += char;
349
- }
350
- }
351
- if (current) {
352
- results.push(current);
353
- }
354
- return results;
355
- }
356
- function stripOuterQuotes(value) {
357
- if (value.startsWith("'") && value.endsWith("'")) {
358
- return value.slice(1, -1);
359
- }
360
- if (value.startsWith('"') && value.endsWith('"')) {
361
- return value.slice(1, -1);
362
- }
363
- return value;
364
- }
365
- function normalizeEntityId(value) {
366
- if (!value.startsWith("file:///")) {
367
- return value;
149
+ seen.add(key);
150
+ deduped.push(entity);
368
151
  }
369
- const idx = value.lastIndexOf("/");
370
- return idx === -1 ? value : value.slice(idx + 1);
152
+ return deduped;
371
153
  }
@@ -1,20 +1,3 @@
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
1
  import { parseAtomList } from "./prolog-list.js";
19
2
  /**
20
3
  * Handle analyze_shared_facts tool calls
@@ -96,7 +79,7 @@ function analyzeSharedConcepts(requirements, existingFacts, minFreq) {
96
79
  const capitalizedTerms = originalText.matchAll(/\b([A-Z][a-z]+)\b/g);
97
80
  // Extract repeated phrases (2+ words)
98
81
  // Extract repeated phrases (2+ words)
99
- const words = text.split(/\s+/).filter(w => w.length > 3);
82
+ const words = text.split(/\s+/).filter((w) => w.length > 3);
100
83
  for (let i = 0; i < words.length - 1; i++) {
101
84
  const phrase = `${words[i]} ${words[i + 1]}`;
102
85
  if (!conceptCounts.has(phrase)) {
@@ -133,6 +116,6 @@ function analyzeSharedConcepts(requirements, existingFacts, minFreq) {
133
116
  function capitalizeConcept(concept) {
134
117
  return concept
135
118
  .split(/\s+/)
136
- .map(word => word.charAt(0).toUpperCase() + word.slice(1))
119
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
137
120
  .join(" ");
138
121
  }
@@ -15,33 +15,6 @@
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
- /*
19
- How to apply this header to source files (examples)
20
-
21
- 1) Prepend header to a single file (POSIX shells):
22
-
23
- cat LICENSE_HEADER.txt "$FILE" > "$FILE".with-header && mv "$FILE".with-header "$FILE"
24
-
25
- 2) Apply to multiple files (example: the project's main entry files):
26
-
27
- for f in packages/cli/bin/kibi packages/mcp/bin/kibi-mcp packages/cli/src/*.ts packages/mcp/src/*.ts; do
28
- if [ -f "$f" ]; then
29
- cp "$f" "$f".bak
30
- (cat LICENSE_HEADER.txt; echo; cat "$f" ) > "$f".new && mv "$f".new "$f"
31
- fi
32
- done
33
-
34
- 3) Avoid duplicating the header: run a quick guard to only add if missing
35
-
36
- for f in packages/cli/bin/kibi packages/mcp/bin/kibi-mcp; do
37
- if [ -f "$f" ]; then
38
- if ! head -n 5 "$f" | grep -q "Copyright (C) 2026 Piotr Franczyk"; then
39
- cp "$f" "$f".bak
40
- (cat LICENSE_HEADER.txt; echo; cat "$f" ) > "$f".new && mv "$f".new "$f"
41
- fi
42
- fi
43
- done
44
- */
45
18
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
46
19
  import path from "node:path";
47
20
  import { dump as dumpYAML, load as parseYAML } from "js-yaml";
@@ -16,18 +16,10 @@
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
20
  import entitySchema from "kibi-cli/schemas/entity";
20
21
  import relationshipSchema from "kibi-cli/schemas/relationship";
21
22
  import { refreshCoordinatesForSymbolId } from "./symbols.js";
22
- function escapeAtom(value) {
23
- return value.replace(/'/g, "''");
24
- }
25
- function toPrologAtom(value) {
26
- const simplePrologAtom = /^[a-z][a-zA-Z0-9_]*$/;
27
- return simplePrologAtom.test(value)
28
- ? value
29
- : `'${value.replace(/'/g, "''")}'`;
30
- }
31
23
  const ajv = new Ajv({ strict: false });
32
24
  const validateEntity = ajv.compile(entitySchema);
33
25
  const validateRelationship = ajv.compile(relationshipSchema);
@@ -122,11 +114,17 @@ export async function handleKbUpsert(prolog, args) {
122
114
  }
123
115
  relationshipsCreated++;
124
116
  }
125
- // Save KB to disk
117
+ // Note: kb_save is intentionally NOT called here for performance.
118
+ // Callers that need durability across restarts should explicitly call kb_save.
119
+ // This allows batching multiple upserts before a single disk write.
120
+ prolog.invalidateCache();
121
+ // Save KB to disk to ensure durability across process restarts
126
122
  await prolog.query("kb_save");
127
123
  prolog.invalidateCache();
124
+ // multiple upserts and save once at the end for better performance.
125
+ prolog.invalidateCache();
128
126
  let contradictionPairsDetected;
129
- if (type === "req") {
127
+ if (type === "req" && !args._skipContradictionCheck) {
130
128
  contradictionPairsDetected = await detectContradictionPairs(prolog, id);
131
129
  }
132
130
  if (type === "symbol") {
@@ -15,37 +15,10 @@
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
- /*
19
- How to apply this header to source files (examples)
20
-
21
- 1) Prepend header to a single file (POSIX shells):
22
-
23
- cat LICENSE_HEADER.txt "$FILE" > "$FILE".with-header && mv "$FILE".with-header "$FILE"
24
-
25
- 2) Apply to multiple files (example: the project's main entry files):
26
-
27
- for f in packages/cli/bin/kibi packages/mcp/bin/kibi-mcp packages/cli/src/*.ts packages/mcp/src/*.ts; do
28
- if [ -f "$f" ]; then
29
- cp "$f" "$f".bak
30
- (cat LICENSE_HEADER.txt; echo; cat "$f" ) > "$f".new && mv "$f".new "$f"
31
- fi
32
- done
33
-
34
- 3) Avoid duplicating the header: run a quick guard to only add if missing
35
-
36
- for f in packages/cli/bin/kibi packages/mcp/bin/kibi-mcp; do
37
- if [ -f "$f" ]; then
38
- if ! head -n 5 "$f" | grep -q "Copyright (C) 2026 Piotr Franczyk"; then
39
- cp "$f" "$f".bak
40
- (cat LICENSE_HEADER.txt; echo; cat "$f" ) > "$f".new && mv "$f".new "$f"
41
- fi
42
- fi
43
- done
44
- */
45
18
  export const TOOLS = [
46
19
  {
47
20
  name: "kb_query",
48
- description: "Read entities from the KB with filters. Use for discovery and lookup before edits. Do not use for writes. No mutation side effects.",
21
+ description: "Read entities from the KB with filters. Use for discovery and lookup before edits. Do not use for writes. No mutation side effects. Tags filter by metadata tags only, not entity IDs.",
49
22
  inputSchema: {
50
23
  type: "object",
51
24
  properties: {
@@ -91,7 +64,7 @@ export const TOOLS = [
91
64
  },
92
65
  {
93
66
  name: "kb_upsert",
94
- 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. Do not use for read-only inspection. Side effects: writes KB, may refresh symbol coordinates.",
67
+ 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.",
95
68
  inputSchema: {
96
69
  type: "object",
97
70
  required: ["type", "id", "properties"],
@@ -228,7 +201,7 @@ export const TOOLS = [
228
201
  },
229
202
  {
230
203
  name: "kb_check",
231
- description: "Run KB validation rules and return violations. Use before or after mutations. Do not use for point lookups. No write side effects.",
204
+ description: "Run KB validation rules and return violations. Use before or after mutations. Do not use for point lookups. No write side effects. Prefer explicit rules for faster iteration.",
232
205
  inputSchema: {
233
206
  type: "object",
234
207
  properties: {
@@ -238,13 +211,16 @@ export const TOOLS = [
238
211
  type: "string",
239
212
  enum: [
240
213
  "must-priority-coverage",
214
+ "symbol-coverage",
215
+ "symbol-traceability",
241
216
  "no-dangling-refs",
242
217
  "no-cycles",
243
218
  "required-fields",
244
- "symbol-coverage",
219
+ "deprecated-adr-no-successor",
220
+ "domain-contradictions",
245
221
  ],
246
222
  },
247
- description: "Optional rule subset. Allowed: must-priority-coverage, no-dangling-refs, no-cycles, required-fields, symbol-coverage. If omitted, server runs all.",
223
+ description: "Optional rule subset. Allowed: must-priority-coverage, symbol-coverage, symbol-traceability, no-dangling-refs, no-cycles, required-fields, deprecated-adr-no-successor, domain-contradictions. If omitted, server runs all.",
248
224
  },
249
225
  },
250
226
  },
package/dist/workspace.js CHANGED
@@ -15,33 +15,6 @@
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
- /*
19
- How to apply this header to source files (examples)
20
-
21
- 1) Prepend header to a single file (POSIX shells):
22
-
23
- cat LICENSE_HEADER.txt "$FILE" > "$FILE".with-header && mv "$FILE".with-header "$FILE"
24
-
25
- 2) Apply to multiple files (example: the project's main entry files):
26
-
27
- for f in packages/cli/bin/kibi packages/mcp/bin/kibi-mcp packages/cli/src/*.ts packages/mcp/src/*.ts; do
28
- if [ -f "$f" ]; then
29
- cp "$f" "$f".bak
30
- (cat LICENSE_HEADER.txt; echo; cat "$f" ) > "$f".new && mv "$f".new "$f"
31
- fi
32
- done
33
-
34
- 3) Avoid duplicating the header: run a quick guard to only add if missing
35
-
36
- for f in packages/cli/bin/kibi packages/mcp/bin/kibi-mcp; do
37
- if [ -f "$f" ]; then
38
- if ! head -n 5 "$f" | grep -q "Copyright (C) 2026 Piotr Franczyk"; then
39
- cp "$f" "$f".bak
40
- (cat LICENSE_HEADER.txt; echo; cat "$f" ) > "$f".new && mv "$f".new "$f"
41
- fi
42
- fi
43
- done
44
- */
45
18
  import fs from "node:fs";
46
19
  import path from "node:path";
47
20
  const WORKSPACE_ENV_KEYS = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-mcp",
3
- "version": "0.2.3",
3
+ "version": "0.3.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.2.3",
13
- "kibi-core": "^0.1.7",
12
+ "kibi-cli": "^0.2.5",
13
+ "kibi-core": "^0.1.8",
14
14
  "mcpcat": "^0.1.12",
15
15
  "ts-morph": "^23.0.0",
16
16
  "zod": "^4.3.6"