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.
- package/bin/kibi-mcp +59 -0
- package/dist/env.js +99 -0
- package/dist/mcpcat.js +129 -0
- package/dist/server.js +673 -0
- package/dist/tools/branch.js +208 -0
- package/dist/tools/check.js +349 -0
- package/dist/tools/context.js +280 -0
- package/dist/tools/coverage-report.js +91 -0
- package/dist/tools/delete.js +100 -0
- package/dist/tools/derive.js +311 -0
- package/dist/tools/impact.js +70 -0
- package/dist/tools/list-types.js +75 -0
- package/dist/tools/prolog-list.js +176 -0
- package/dist/tools/query-relationships.js +176 -0
- package/dist/tools/query.js +364 -0
- package/dist/tools/suggest-shared-facts.js +138 -0
- package/dist/tools/symbols.js +219 -0
- package/dist/tools/upsert.js +228 -0
- package/dist/tools-config.js +448 -0
- package/dist/workspace.js +126 -0
- package/package.json +43 -0
|
@@ -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
|
+
}
|