kibi-mcp 0.1.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.
@@ -0,0 +1,219 @@
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
+ 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
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
46
+ import path from "node:path";
47
+ import { resolveWorkspaceRoot } from "../workspace.js";
48
+ import { enrichSymbolCoordinates, } from "kibi-cli/extractors/symbols-coordinator";
49
+ import { dump as dumpYAML, load as parseYAML } from "js-yaml";
50
+ const COMMENT_BLOCK = `# symbols.yaml
51
+ # AUTHORED fields (edit freely):
52
+ # id, title, sourceFile, links, status, tags, owner, priority
53
+ # GENERATED fields (never edit manually — overwritten by kibi sync and kb_symbols_refresh):
54
+ # sourceLine, sourceColumn, sourceEndLine, sourceEndColumn, coordinatesGeneratedAt
55
+ # Run \`kibi sync\` or call the \`kb_symbols_refresh\` MCP tool to refresh coordinates.
56
+ `;
57
+ const GENERATED_COORD_FIELDS = [
58
+ "sourceLine",
59
+ "sourceColumn",
60
+ "sourceEndLine",
61
+ "sourceEndColumn",
62
+ "coordinatesGeneratedAt",
63
+ ];
64
+ const SOURCE_EXTENSIONS = new Set([
65
+ ".ts",
66
+ ".tsx",
67
+ ".js",
68
+ ".jsx",
69
+ ".mts",
70
+ ".cts",
71
+ ".mjs",
72
+ ".cjs",
73
+ ]);
74
+ export async function handleKbSymbolsRefresh(args) {
75
+ const dryRun = args.dryRun === true;
76
+ const workspaceRoot = resolveWorkspaceRoot();
77
+ const manifestPath = resolveManifestPath(workspaceRoot);
78
+ const rawContent = readFileSync(manifestPath, "utf8");
79
+ const parsed = parseYAML(rawContent);
80
+ if (!isRecord(parsed) || !Array.isArray(parsed.symbols)) {
81
+ throw new Error(`Invalid symbols manifest at ${manifestPath}`);
82
+ }
83
+ const original = parsed.symbols.map((entry) => isRecord(entry)
84
+ ? { ...entry }
85
+ : {});
86
+ const entriesForEnrichment = original.map((entry) => ({
87
+ ...entry,
88
+ id: typeof entry.id === "string" ? entry.id : "",
89
+ title: typeof entry.title === "string" ? entry.title : "",
90
+ }));
91
+ const enriched = await enrichSymbolCoordinates(entriesForEnrichment, workspaceRoot);
92
+ parsed.symbols = enriched;
93
+ let refreshed = 0;
94
+ let failed = 0;
95
+ let unchanged = 0;
96
+ for (let i = 0; i < original.length; i++) {
97
+ const before = original[i] ?? {};
98
+ const after = enriched[i] ?? before;
99
+ const changed = GENERATED_COORD_FIELDS.some((field) => before[field] !== after[field]);
100
+ if (changed) {
101
+ refreshed++;
102
+ continue;
103
+ }
104
+ const source = typeof after.sourceFile === "string"
105
+ ? after.sourceFile
106
+ : typeof before.sourceFile === "string"
107
+ ? before.sourceFile
108
+ : undefined;
109
+ const eligible = isEligible(source, workspaceRoot);
110
+ if (eligible && !hasGeneratedCoordinates(after)) {
111
+ failed++;
112
+ }
113
+ else {
114
+ unchanged++;
115
+ }
116
+ }
117
+ const dumped = dumpYAML(parsed, {
118
+ lineWidth: -1,
119
+ noRefs: true,
120
+ sortKeys: false,
121
+ });
122
+ const nextContent = `${COMMENT_BLOCK}${dumped}`;
123
+ if (!dryRun && rawContent !== nextContent) {
124
+ writeFileSync(manifestPath, nextContent, "utf8");
125
+ }
126
+ return {
127
+ content: [
128
+ {
129
+ type: "text",
130
+ text: `kb_symbols_refresh ${dryRun ? "(dry run) " : ""}completed for ${path.relative(workspaceRoot, manifestPath)}: refreshed=${refreshed}, unchanged=${unchanged}, failed=${failed}`,
131
+ },
132
+ ],
133
+ structuredContent: {
134
+ refreshed,
135
+ failed,
136
+ unchanged,
137
+ dryRun,
138
+ },
139
+ };
140
+ }
141
+ export async function refreshCoordinatesForSymbolId(symbolId, workspaceRoot = resolveWorkspaceRoot()) {
142
+ const manifestPath = resolveManifestPath(workspaceRoot);
143
+ const rawContent = readFileSync(manifestPath, "utf8");
144
+ const parsed = parseYAML(rawContent);
145
+ if (!isRecord(parsed) || !Array.isArray(parsed.symbols)) {
146
+ return { refreshed: false, found: false };
147
+ }
148
+ const symbols = parsed.symbols.map((entry) => isRecord(entry)
149
+ ? { ...entry }
150
+ : {});
151
+ const index = symbols.findIndex((entry) => entry.id === symbolId);
152
+ if (index < 0) {
153
+ return { refreshed: false, found: false };
154
+ }
155
+ const original = symbols[index] ?? {};
156
+ const singleEntry = {
157
+ ...original,
158
+ id: typeof original.id === "string"
159
+ ? original.id
160
+ : "",
161
+ title: typeof original.title === "string"
162
+ ? original.title
163
+ : "",
164
+ };
165
+ const [enriched] = await enrichSymbolCoordinates([singleEntry], workspaceRoot);
166
+ symbols[index] = enriched ?? original;
167
+ parsed.symbols = symbols;
168
+ const refreshed = GENERATED_COORD_FIELDS.some((field) => original[field] !== symbols[index][field]);
169
+ const dumped = dumpYAML(parsed, {
170
+ lineWidth: -1,
171
+ noRefs: true,
172
+ sortKeys: false,
173
+ });
174
+ const nextContent = `${COMMENT_BLOCK}${dumped}`;
175
+ if (rawContent !== nextContent) {
176
+ writeFileSync(manifestPath, nextContent, "utf8");
177
+ }
178
+ return { refreshed, found: true };
179
+ }
180
+ function resolveManifestPath(workspaceRoot) {
181
+ const configPath = path.join(workspaceRoot, ".kb", "config.json");
182
+ if (existsSync(configPath)) {
183
+ try {
184
+ const config = JSON.parse(readFileSync(configPath, "utf8"));
185
+ if (config.symbolsManifest) {
186
+ return path.isAbsolute(config.symbolsManifest)
187
+ ? config.symbolsManifest
188
+ : path.resolve(workspaceRoot, config.symbolsManifest);
189
+ }
190
+ }
191
+ catch { }
192
+ }
193
+ const candidates = [
194
+ path.join(workspaceRoot, "symbols.yaml"),
195
+ path.join(workspaceRoot, "symbols.yml"),
196
+ ];
197
+ return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
198
+ }
199
+ function hasGeneratedCoordinates(entry) {
200
+ return (typeof entry.sourceLine === "number" &&
201
+ typeof entry.sourceColumn === "number" &&
202
+ typeof entry.sourceEndLine === "number" &&
203
+ typeof entry.sourceEndColumn === "number" &&
204
+ typeof entry.coordinatesGeneratedAt === "string" &&
205
+ entry.coordinatesGeneratedAt.length > 0);
206
+ }
207
+ function isEligible(sourceFile, workspaceRoot) {
208
+ if (!sourceFile)
209
+ return false;
210
+ const absolute = path.isAbsolute(sourceFile)
211
+ ? sourceFile
212
+ : path.resolve(workspaceRoot, sourceFile);
213
+ if (!existsSync(absolute))
214
+ return false;
215
+ return SOURCE_EXTENSIONS.has(path.extname(absolute).toLowerCase());
216
+ }
217
+ function isRecord(value) {
218
+ return typeof value === "object" && value !== null && !Array.isArray(value);
219
+ }
@@ -0,0 +1,228 @@
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 entitySchema from "kibi-cli/schemas/entity";
19
+ import relationshipSchema from "kibi-cli/schemas/relationship";
20
+ import Ajv from "ajv";
21
+ import { refreshCoordinatesForSymbolId } from "./symbols.js";
22
+ const ajv = new Ajv({ strict: false });
23
+ const validateEntity = ajv.compile(entitySchema);
24
+ const validateRelationship = ajv.compile(relationshipSchema);
25
+ /**
26
+ * Handle kb.upsert tool calls
27
+ * Accepts { type, id, properties } — the flat format matching the tool schema.
28
+ * Validates the assembled entity against JSON Schema before Prolog writes.
29
+ */
30
+ export async function handleKbUpsert(prolog, args) {
31
+ const { type, id, properties, relationships = [] } = args;
32
+ if (!type || !id) {
33
+ throw new Error("'type' and 'id' are required for upsert");
34
+ }
35
+ // Assemble full entity from flat args + properties
36
+ const entity = {
37
+ id,
38
+ type,
39
+ ...properties,
40
+ };
41
+ // Fill in defaults for optional required fields
42
+ if (!entity.created_at) {
43
+ entity.created_at = new Date().toISOString();
44
+ }
45
+ if (!entity.updated_at) {
46
+ entity.updated_at = new Date().toISOString();
47
+ }
48
+ if (!entity.source) {
49
+ entity.source = "mcp://kibi/upsert";
50
+ }
51
+ const entities = [entity];
52
+ // Validate all entities
53
+ for (let i = 0; i < entities.length; i++) {
54
+ const ent = entities[i];
55
+ if (!validateEntity(ent)) {
56
+ const errors = validateEntity.errors || [];
57
+ const errorMessages = errors
58
+ .map((e) => `${e.instancePath || "root"}: ${e.message}`)
59
+ .join("; ");
60
+ throw new Error(`Entity validation failed: ${errorMessages}`);
61
+ }
62
+ }
63
+ // Validate all relationships
64
+ for (let i = 0; i < relationships.length; i++) {
65
+ const rel = relationships[i];
66
+ if (!validateRelationship(rel)) {
67
+ const errors = validateRelationship.errors || [];
68
+ const errorMessages = errors
69
+ .map((e) => `${e.instancePath || "root"}: ${e.message}`)
70
+ .join("; ");
71
+ throw new Error(`Relationship validation failed at index ${i}: ${errorMessages}`);
72
+ }
73
+ }
74
+ let created = 0;
75
+ let updated = 0;
76
+ let relationshipsCreated = 0;
77
+ try {
78
+ // Process entities
79
+ for (const entity of entities) {
80
+ const id = entity.id;
81
+ const type = entity.type;
82
+ // Check if entity exists
83
+ const checkGoal = `kb_entity('${id}', _, _)`;
84
+ const checkResult = await prolog.query(checkGoal);
85
+ const isUpdate = checkResult.success;
86
+ // Build property list for Prolog
87
+ const props = buildPropertyList(entity);
88
+ // Assert entity (upsert)
89
+ if (isUpdate) {
90
+ // Delete old version, then insert new
91
+ const retractGoal = `kb_retract_entity('${id}')`;
92
+ await prolog.query(retractGoal);
93
+ updated++;
94
+ }
95
+ else {
96
+ created++;
97
+ }
98
+ const assertGoal = `kb_assert_entity(${type}, ${props})`;
99
+ const assertResult = await prolog.query(assertGoal);
100
+ if (!assertResult.success) {
101
+ throw new Error(`Failed to assert entity ${id}: ${assertResult.error || "Unknown error"}`);
102
+ }
103
+ }
104
+ // Process relationships
105
+ for (const rel of relationships) {
106
+ const relType = rel.type;
107
+ const from = rel.from;
108
+ const to = rel.to;
109
+ // Build metadata
110
+ const metadata = buildRelationshipMetadata(rel);
111
+ const relGoal = `kb_assert_relationship(${relType}, '${from}', '${to}', ${metadata})`;
112
+ const relResult = await prolog.query(relGoal);
113
+ if (!relResult.success) {
114
+ throw new Error(`Failed to assert relationship ${relType} from ${from} to ${to}: ${relResult.error || "Unknown error"}`);
115
+ }
116
+ relationshipsCreated++;
117
+ }
118
+ // Save KB to disk
119
+ await prolog.query("kb_save");
120
+ if (type === "symbol") {
121
+ try {
122
+ await refreshCoordinatesForSymbolId(id);
123
+ }
124
+ catch (error) {
125
+ const message = error instanceof Error ? error.message : String(error);
126
+ if (process.env.KIBI_MCP_DEBUG) {
127
+ console.warn(`[KIBI-MCP] Symbol coordinate auto-refresh failed for ${id}: ${message}`);
128
+ }
129
+ }
130
+ }
131
+ return {
132
+ content: [
133
+ {
134
+ type: "text",
135
+ text: `Upserted ${id} (${created > 0 ? "created" : "updated"}) with ${relationshipsCreated} relationship(s).`,
136
+ },
137
+ ],
138
+ structuredContent: {
139
+ created,
140
+ updated,
141
+ relationships_created: relationshipsCreated,
142
+ },
143
+ };
144
+ }
145
+ catch (error) {
146
+ const message = error instanceof Error ? error.message : String(error);
147
+ throw new Error(`Upsert execution failed: ${message}`);
148
+ }
149
+ }
150
+ /**
151
+ * Build Prolog property list from entity object
152
+ * Returns simple Key=Value format without typed literals
153
+ * Example output: "[id='test-1', title=\"Test\", status=active]"
154
+ */
155
+ function buildPropertyList(entity) {
156
+ const pairs = [];
157
+ // Defined internally to ensure thread safety and avoid initialization order issues.
158
+ // Using simple arrays instead of Sets is performant enough for small lists and avoids Set allocation overhead.
159
+ const ATOM_FIELDS = ["status", "owner", "priority", "severity"];
160
+ const STRING_FIELDS = [
161
+ "id",
162
+ "title",
163
+ "created_at",
164
+ "updated_at",
165
+ "source",
166
+ "text_ref",
167
+ ];
168
+ for (const [key, value] of Object.entries(entity)) {
169
+ if (key === "type")
170
+ continue;
171
+ let prologValue;
172
+ if (key === "id" && typeof value === "string") {
173
+ prologValue = `'${value}'`;
174
+ }
175
+ else if (Array.isArray(value)) {
176
+ prologValue = JSON.stringify(value);
177
+ }
178
+ else if (ATOM_FIELDS.includes(key) && typeof value === "string") {
179
+ prologValue = value;
180
+ }
181
+ else if (STRING_FIELDS.includes(key) && typeof value === "string") {
182
+ prologValue = `"${escapeQuotes(value)}"`;
183
+ }
184
+ else if (typeof value === "string") {
185
+ prologValue = `"${escapeQuotes(value)}"`;
186
+ }
187
+ else if (typeof value === "number") {
188
+ prologValue = String(value);
189
+ }
190
+ else if (typeof value === "boolean") {
191
+ prologValue = value ? "true" : "false";
192
+ }
193
+ else {
194
+ prologValue = `"${escapeQuotes(String(value))}"`;
195
+ }
196
+ pairs.push(`${key}=${prologValue}`);
197
+ }
198
+ return `[${pairs.join(", ")}]`;
199
+ }
200
+ /**
201
+ * Build Prolog metadata list for relationship
202
+ * Returns simple Key=Value format without typed literals
203
+ */
204
+ function buildRelationshipMetadata(rel) {
205
+ const pairs = [];
206
+ for (const [key, value] of Object.entries(rel)) {
207
+ if (key === "type" || key === "from" || key === "to")
208
+ continue;
209
+ let prologValue;
210
+ if (typeof value === "string") {
211
+ prologValue = `"${escapeQuotes(value)}"`;
212
+ }
213
+ else if (typeof value === "number") {
214
+ prologValue = String(value);
215
+ }
216
+ else {
217
+ prologValue = `"${escapeQuotes(String(value))}"`;
218
+ }
219
+ pairs.push(`${key}=${prologValue}`);
220
+ }
221
+ return `[${pairs.join(", ")}]`;
222
+ }
223
+ /**
224
+ * Escape double quotes in strings for Prolog
225
+ */
226
+ function escapeQuotes(str) {
227
+ return str.replace(/"/g, '\\"');
228
+ }