kibi-cli 0.18.1 → 0.19.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.
Files changed (89) hide show
  1. package/dist/cli-json-command.d.ts +1 -2
  2. package/dist/cli-json-command.d.ts.map +1 -1
  3. package/dist/cli-json-command.js +2 -34
  4. package/dist/cli-operation-loader.d.ts +3 -0
  5. package/dist/cli-operation-loader.d.ts.map +1 -0
  6. package/dist/cli-operation-loader.js +58 -0
  7. package/dist/cli-operation-metadata.d.ts +82 -0
  8. package/dist/cli-operation-metadata.d.ts.map +1 -0
  9. package/dist/cli-operation-metadata.js +106 -0
  10. package/dist/cli-protocol.d.ts +1 -1
  11. package/dist/cli-protocol.d.ts.map +1 -1
  12. package/dist/cli-protocol.js +4 -3
  13. package/dist/cli-register-foundation.d.ts.map +1 -1
  14. package/dist/cli-register-foundation.js +14 -17
  15. package/dist/cli-register-json.d.ts +3 -0
  16. package/dist/cli-register-json.d.ts.map +1 -0
  17. package/dist/cli-register-json.js +35 -0
  18. package/dist/cli-register-maintenance.d.ts.map +1 -1
  19. package/dist/cli-register-maintenance.js +35 -10
  20. package/dist/cli-register-reporting.d.ts.map +1 -1
  21. package/dist/cli-register-reporting.js +13 -14
  22. package/dist/cli-register-skills.d.ts.map +1 -1
  23. package/dist/cli-register-skills.js +8 -6
  24. package/dist/cli.js +1 -1
  25. package/dist/commands/check.d.ts.map +1 -1
  26. package/dist/commands/check.js +30 -12
  27. package/dist/commands/coverage.js +1 -1
  28. package/dist/commands/discovery-shared.d.ts +2 -1
  29. package/dist/commands/discovery-shared.d.ts.map +1 -1
  30. package/dist/commands/discovery-shared.js +35 -13
  31. package/dist/commands/engine.d.ts +8 -0
  32. package/dist/commands/engine.d.ts.map +1 -0
  33. package/dist/commands/engine.js +100 -0
  34. package/dist/commands/gaps.js +1 -1
  35. package/dist/commands/graph.js +1 -1
  36. package/dist/commands/query.d.ts.map +1 -1
  37. package/dist/commands/query.js +3 -1
  38. package/dist/commands/search.d.ts.map +1 -1
  39. package/dist/commands/search.js +3 -1
  40. package/dist/commands/status.d.ts.map +1 -1
  41. package/dist/commands/status.js +2 -1
  42. package/dist/commands/sync/cache.d.ts +10 -0
  43. package/dist/commands/sync/cache.d.ts.map +1 -1
  44. package/dist/commands/sync/cache.js +30 -1
  45. package/dist/commands/sync/persistence.d.ts +10 -1
  46. package/dist/commands/sync/persistence.d.ts.map +1 -1
  47. package/dist/commands/sync/persistence.js +220 -44
  48. package/dist/commands/sync/staging.d.ts +10 -1
  49. package/dist/commands/sync/staging.d.ts.map +1 -1
  50. package/dist/commands/sync/staging.js +84 -1
  51. package/dist/commands/sync.d.ts.map +1 -1
  52. package/dist/commands/sync.js +450 -13
  53. package/dist/engine-daemon.d.ts +3 -0
  54. package/dist/engine-daemon.d.ts.map +1 -0
  55. package/dist/engine-daemon.js +34 -0
  56. package/dist/engine.d.ts +63 -0
  57. package/dist/engine.d.ts.map +1 -0
  58. package/dist/engine.js +1256 -0
  59. package/dist/extractors/relationships.d.ts +2 -0
  60. package/dist/extractors/relationships.d.ts.map +1 -1
  61. package/dist/extractors/relationships.js +15 -17
  62. package/dist/prolog.d.ts +8 -0
  63. package/dist/prolog.d.ts.map +1 -1
  64. package/dist/prolog.js +39 -8
  65. package/dist/public/operations/discovery-entities.d.ts.map +1 -1
  66. package/dist/public/operations/discovery-entities.js +3 -2
  67. package/dist/public/operations/discovery-executors.d.ts.map +1 -1
  68. package/dist/public/operations/discovery-executors.js +40 -3
  69. package/dist/public/operations/prolog-json.d.ts.map +1 -1
  70. package/dist/public/operations/prolog-json.js +14 -4
  71. package/dist/public/operations/runtime-types.d.ts +31 -0
  72. package/dist/public/operations/runtime-types.d.ts.map +1 -1
  73. package/dist/public/skills/init-kibi/SKILL.md +2 -2
  74. package/dist/public/skills/kibi-freshness/SKILL.md +2 -2
  75. package/dist/public/skills/kibi-traceability/SKILL.md +1 -1
  76. package/dist/public/skills/kibi-usage/SKILL.md +2 -2
  77. package/dist/public/skills/kibi-usage/resources/operation-access.md +1 -1
  78. package/dist/runtime/cli-runtime.d.ts.map +1 -1
  79. package/dist/runtime/cli-runtime.js +33 -37
  80. package/package.json +8 -4
  81. package/src/public/operations/discovery-entities.ts +3 -2
  82. package/src/public/operations/discovery-executors.ts +44 -3
  83. package/src/public/operations/prolog-json.ts +15 -3
  84. package/src/public/operations/runtime-types.ts +39 -0
  85. package/src/public/skills/init-kibi/SKILL.md +2 -2
  86. package/src/public/skills/kibi-freshness/SKILL.md +2 -2
  87. package/src/public/skills/kibi-traceability/SKILL.md +1 -1
  88. package/src/public/skills/kibi-usage/SKILL.md +2 -2
  89. package/src/public/skills/kibi-usage/resources/operation-access.md +1 -1
package/dist/engine.js ADDED
@@ -0,0 +1,1256 @@
1
+ /*
2
+ * Kibi — single-writer workspace engine and framed RPC client.
3
+ *
4
+ * The engine deliberately owns one interactive SWI-Prolog process for one
5
+ * workspace/branch. CLI and MCP callers only exchange typed envelopes over
6
+ * the local socket; Prolog itself never crosses the process boundary.
7
+ */
8
+ import { spawn } from "node:child_process";
9
+ import { createHash } from "node:crypto";
10
+ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
11
+ import * as net from "node:net";
12
+ import * as os from "node:os";
13
+ import * as path from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { PrologProcess, resolveKbPlPath } from "./prolog.js";
16
+ import { parseEntityFromList, parseListOfLists } from "./prolog/codec.js";
17
+ import { isValidBranchName } from "./utils/branch-resolver.js";
18
+ export const ENGINE_PROTOCOL_VERSION = 1;
19
+ export const ENGINE_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
20
+ export const ENGINE_PACKAGE_VERSIONS = process.env.KIBI_PACKAGE_VERSIONS ?? "unknown";
21
+ const ENGINE_QUERY_CACHE_MAX_ENTRIES = 128;
22
+ const ENGINE_QUERY_CACHE_MAX_RESULT_BYTES = 8 * 1024 * 1024;
23
+ const ENGINE_FRESHNESS_CACHE_MS = 100;
24
+ function engineIdleTimeoutMs() {
25
+ const configured = Number.parseInt(process.env.KIBI_ENGINE_IDLE_TIMEOUT_MS ?? "", 10);
26
+ return Number.isFinite(configured) && configured >= 100
27
+ ? configured
28
+ : ENGINE_IDLE_TIMEOUT_MS;
29
+ }
30
+ function quoteProlog(value) {
31
+ return value.replaceAll("'", "''");
32
+ }
33
+ function cacheableEngineGoal(goal) {
34
+ const normalized = goal.trim();
35
+ if (!normalized ||
36
+ normalized.startsWith("(") ||
37
+ normalized.includes("kb_storage_status")) {
38
+ return false;
39
+ }
40
+ // The daemon cache is deliberately limited to deterministic discovery and
41
+ // graph reads. Status/freshness reads external files and writes invalidate
42
+ // the entire cache before they can be observed by another client.
43
+ return (normalized.startsWith("findall(") ||
44
+ normalized.startsWith("kb_entity(") ||
45
+ normalized.startsWith("kb_entities_by_source(") ||
46
+ normalized.startsWith("kb_relationship("));
47
+ }
48
+ function mutatingEngineGoal(goal) {
49
+ return /(?:kb_(?:assert|retract|commit|save|delete)|rdf_transaction|rdf_assert|rdf_retract)/.test(goal);
50
+ }
51
+ function safeEngineGoal(goal) {
52
+ // The socket is a local capability boundary, not a general-purpose SWI
53
+ // console. Public clients may compose the typed Kibi predicates needed by
54
+ // the 18 operation contracts, but process/filesystem/network escape hatches
55
+ // are rejected before they reach Prolog.
56
+ if (/\b(?:halt|abort|shell|system|process_create|consult|load_files|open|close|delete_file|rename_file|make_directory|thread_create|rdf_attach_db|rdf_load|rdf_save|assertz|asserta|retractall)\s*\(/.test(goal)) {
57
+ return false;
58
+ }
59
+ return /(?:^|[(:,\s])(?:kb_|checks:|status:|discovery:|requirement_proof:|findall\(|aggregate_all\(|rdf_transaction\(|set_prolog_flag\(|use_module\(|atom_json_dict\(|member\(|once\(|true\b|fail\b)/.test(goal.trim());
60
+ }
61
+ function frame(value) {
62
+ const payload = Buffer.from(JSON.stringify(value), "utf8");
63
+ const header = Buffer.allocUnsafe(4);
64
+ header.writeUInt32BE(payload.byteLength, 0);
65
+ return Buffer.concat([header, payload]);
66
+ }
67
+ function writeSocketFrame(socket, value) {
68
+ if (socket.destroyed || !socket.writable)
69
+ return;
70
+ try {
71
+ socket.write(frame(value));
72
+ }
73
+ catch {
74
+ // A client may disconnect while a queued request is finishing. The
75
+ // journal transaction remains authoritative; there is no response to
76
+ // deliver to a closed peer.
77
+ }
78
+ }
79
+ function parseFrames(buffer, onFrame) {
80
+ let cursor = 0;
81
+ while (buffer.byteLength - cursor >= 4) {
82
+ const length = buffer.readUInt32BE(cursor);
83
+ if (length > 64 * 1024 * 1024) {
84
+ throw new Error("Kibi engine frame exceeds 64 MiB limit");
85
+ }
86
+ if (buffer.byteLength - cursor - 4 < length)
87
+ break;
88
+ const payload = buffer.subarray(cursor + 4, cursor + 4 + length);
89
+ onFrame(JSON.parse(payload.toString("utf8")));
90
+ cursor += 4 + length;
91
+ }
92
+ return buffer.subarray(cursor);
93
+ }
94
+ function runtimeDirectory() {
95
+ const configured = process.env.KIBI_RUNTIME_DIR ??
96
+ process.env.XDG_RUNTIME_DIR ??
97
+ path.join(os.tmpdir(), "kibi-runtime");
98
+ const candidates = [configured, path.join(os.tmpdir(), "kibi-runtime")];
99
+ for (const candidate of candidates) {
100
+ try {
101
+ mkdirSync(candidate, { recursive: true, mode: 0o700 });
102
+ // XDG_RUNTIME_DIR can exist but be mounted read-only (for example in a
103
+ // constrained container). Probe the directory before using it for the
104
+ // socket, start lock, and pid files so auto-start fails over cleanly.
105
+ const probe = path.join(candidate, `.kibi-write-${process.pid}`);
106
+ writeFileSync(probe, "", { mode: 0o600 });
107
+ unlinkSync(probe);
108
+ return candidate;
109
+ }
110
+ catch {
111
+ // Try the private system-temp fallback below.
112
+ }
113
+ }
114
+ throw new Error("Unable to create a writable Kibi engine runtime directory");
115
+ }
116
+ function fsyncPath(filePath) {
117
+ if (!existsSync(filePath))
118
+ return;
119
+ let descriptor = null;
120
+ try {
121
+ descriptor = openSync(filePath, "r");
122
+ fsyncSync(descriptor);
123
+ }
124
+ finally {
125
+ if (descriptor !== null)
126
+ closeSync(descriptor);
127
+ }
128
+ }
129
+ /**
130
+ * Cross the kernel durability boundary before a journaled mutation is
131
+ * acknowledged. SWI flushes completed RDF transaction records to its journal
132
+ * stream; fsyncing the journal and atomic CURRENT pointer makes that flush
133
+ * survive a host crash as well as a process crash.
134
+ */
135
+ // implements REQ-core-journaled-engine-persistence
136
+ export function fsyncJournaledBranchStore(branchPath) {
137
+ const rdfRoot = path.join(branchPath, "rdf");
138
+ const directories = [];
139
+ const pending = existsSync(rdfRoot) ? [rdfRoot] : [];
140
+ while (pending.length > 0) {
141
+ const directory = pending.pop();
142
+ if (directory === undefined)
143
+ break;
144
+ directories.push(directory);
145
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
146
+ const entryPath = path.join(directory, entry.name);
147
+ if (entry.isDirectory())
148
+ pending.push(entryPath);
149
+ else if (entry.isFile() && entry.name !== "lock")
150
+ fsyncPath(entryPath);
151
+ }
152
+ }
153
+ for (const metadata of ["CURRENT", "storage.json", "kb.rdf"]) {
154
+ fsyncPath(path.join(branchPath, metadata));
155
+ }
156
+ // Publish directory entries from the leaves upward, ending at the branch
157
+ // directory that owns CURRENT and the storage marker.
158
+ if (process.platform !== "win32") {
159
+ for (const directory of directories.reverse())
160
+ fsyncPath(directory);
161
+ fsyncPath(branchPath);
162
+ }
163
+ }
164
+ // implements REQ-core-journaled-engine-persistence
165
+ export function engineSocketPath(workspaceRoot, branch) {
166
+ const canonicalRoot = (() => {
167
+ try {
168
+ return realpathSync(workspaceRoot);
169
+ }
170
+ catch {
171
+ return path.resolve(workspaceRoot);
172
+ }
173
+ })();
174
+ const key = createHash("sha256")
175
+ .update(`${canonicalRoot}\0${branch}`)
176
+ // 128 bits keeps collision risk negligible while staying below the
177
+ // roughly 108-byte sockaddr_un limit even in a nested XDG runtime path.
178
+ .digest("hex")
179
+ .slice(0, 32);
180
+ if (process.platform === "win32") {
181
+ return `\\\\.\\pipe\\kibi-${key}`;
182
+ }
183
+ return path.join(runtimeDirectory(), `kibi-${key}.sock`);
184
+ }
185
+ export function enginePidPath(workspaceRoot, branch) {
186
+ const socket = engineSocketPath(workspaceRoot, branch);
187
+ if (process.platform === "win32") {
188
+ return path.join(runtimeDirectory(), `kibi-${createHash("sha256")
189
+ .update(`${path.resolve(workspaceRoot)}\0${branch}`)
190
+ .digest("hex")}.pid`);
191
+ }
192
+ return `${socket}.pid`;
193
+ }
194
+ export function engineStartLockPath(workspaceRoot, branch) {
195
+ if (process.platform === "win32") {
196
+ return path.join(runtimeDirectory(), `kibi-${createHash("sha256")
197
+ .update(`${path.resolve(workspaceRoot)}\0${branch}`)
198
+ .digest("hex")}.start.lock`);
199
+ }
200
+ return `${engineSocketPath(workspaceRoot, branch)}.start.lock`;
201
+ }
202
+ function tryAcquireStartLock(lockPath) {
203
+ try {
204
+ const descriptor = openSync(lockPath, "wx", 0o600);
205
+ writeFileSync(lockPath, `${process.pid}:${Date.now()}\n`, { flag: "w" });
206
+ return descriptor;
207
+ }
208
+ catch {
209
+ // Remove a lock left by a crashed starter, but never steal one from a
210
+ // live process. A short age grace period avoids racing a slow Node start.
211
+ try {
212
+ const stamp = readFileSync(lockPath, "utf8").trim().split(":");
213
+ const pid = Number.parseInt(stamp[0] ?? "", 10);
214
+ const created = Number.parseInt(stamp[1] ?? "", 10);
215
+ let age = 0;
216
+ try {
217
+ age = Date.now() - statSync(lockPath).mtimeMs;
218
+ }
219
+ catch {
220
+ age = 0;
221
+ }
222
+ if (!Number.isFinite(created) && age <= 5_000)
223
+ return null;
224
+ let alive = false;
225
+ if (Number.isFinite(pid) && pid > 0) {
226
+ try {
227
+ process.kill(pid, 0);
228
+ alive = true;
229
+ }
230
+ catch {
231
+ alive = false;
232
+ }
233
+ }
234
+ if (!alive &&
235
+ (Number.isFinite(created) ? Date.now() - created > 5_000 : age > 5_000)) {
236
+ unlinkSync(lockPath);
237
+ }
238
+ }
239
+ catch {
240
+ // The other starter may be publishing/removing the lock right now.
241
+ }
242
+ return null;
243
+ }
244
+ }
245
+ function releaseStartLock(lockPath, descriptor) {
246
+ if (descriptor !== null) {
247
+ try {
248
+ closeSync(descriptor);
249
+ }
250
+ catch {
251
+ // best effort
252
+ }
253
+ }
254
+ try {
255
+ unlinkSync(lockPath);
256
+ }
257
+ catch {
258
+ // Another starter may already have cleaned the lock.
259
+ }
260
+ }
261
+ function recoverStaleJournalLock(branchPath) {
262
+ const lockPath = path.join(branchPath, "rdf", "lock");
263
+ if (!existsSync(lockPath))
264
+ return;
265
+ let contents = "";
266
+ try {
267
+ contents = readFileSync(lockPath, "utf8");
268
+ }
269
+ catch {
270
+ return;
271
+ }
272
+ const match = /pid\((\d+)\)/.exec(contents);
273
+ if (!match)
274
+ return;
275
+ const pid = Number.parseInt(match[1] ?? "", 10);
276
+ if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid)
277
+ return;
278
+ try {
279
+ process.kill(pid, 0);
280
+ return;
281
+ }
282
+ catch {
283
+ try {
284
+ rmSync(lockPath, { force: true });
285
+ }
286
+ catch {
287
+ // Let rdf_attach_db report the lock error if another process wins the
288
+ // race between the liveness probe and removal.
289
+ }
290
+ }
291
+ }
292
+ function daemonEntry() {
293
+ const moduleDirectory = path.dirname(fileURLToPath(import.meta.url));
294
+ // Packed clients keep the daemon beside engine.js. Source-tree repository
295
+ // tests resolve engine.ts from src/, while the daemon is hosted by the
296
+ // already-built Node artifact in dist/.
297
+ const candidates = [
298
+ path.join(moduleDirectory, "engine-daemon.js"),
299
+ path.join(moduleDirectory, "..", "dist", "engine-daemon.js"),
300
+ // Sandboxed/evaluation hosts may bundle the MCP server into their own
301
+ // dist/ directory while preserving kibi-cli as a staged dependency. Keep
302
+ // the Node daemon external to that bundle so it can resolve its Prolog and
303
+ // codec modules exactly as the published CLI package does.
304
+ path.join(moduleDirectory, "..", "node_modules", "kibi-cli", "dist", "engine-daemon.js"),
305
+ ];
306
+ for (const candidate of candidates) {
307
+ if (existsSync(candidate))
308
+ return candidate;
309
+ }
310
+ throw new Error("The Kibi engine is not built. Run `npm run build:cli` before using the CLI engine.");
311
+ }
312
+ function connectSocket(socketPath, timeoutMs) {
313
+ return new Promise((resolve, reject) => {
314
+ const socket = net.createConnection(socketPath);
315
+ let settled = false;
316
+ const timer = setTimeout(() => {
317
+ if (settled)
318
+ return;
319
+ settled = true;
320
+ socket.destroy();
321
+ reject(new Error(`Timed out connecting to Kibi engine at ${socketPath}`));
322
+ }, timeoutMs);
323
+ socket.once("connect", () => {
324
+ if (settled)
325
+ return;
326
+ settled = true;
327
+ clearTimeout(timer);
328
+ resolve(socket);
329
+ });
330
+ // Keep a permanent error listener. Some Node versions emit the
331
+ // connection-refused error on the next tick after a timeout destroys the
332
+ // socket; a once-listener can otherwise become an uncaught event during
333
+ // concurrent auto-start attempts.
334
+ socket.on("error", (error) => {
335
+ if (settled)
336
+ return;
337
+ settled = true;
338
+ clearTimeout(timer);
339
+ reject(error);
340
+ });
341
+ });
342
+ }
343
+ async function connectWithRetry(socketPath, timeoutMs) {
344
+ const deadline = Date.now() + timeoutMs;
345
+ let lastError;
346
+ while (Date.now() < deadline) {
347
+ const errorPath = `${socketPath}.error`;
348
+ if (existsSync(errorPath)) {
349
+ try {
350
+ const diagnostic = readFileSync(errorPath, "utf8").trim();
351
+ if (diagnostic)
352
+ throw new Error(diagnostic);
353
+ }
354
+ catch (error) {
355
+ throw new Error(`Kibi engine failed to start: ${error instanceof Error ? error.message : String(error)}`);
356
+ }
357
+ }
358
+ // Avoid creating a refused/ENOENT socket handle on every poll while the
359
+ // daemon is still starting and avoid noisy late socket errors.
360
+ if (process.platform !== "win32" && !existsSync(socketPath)) {
361
+ await new Promise((resolve) => setTimeout(resolve, 25));
362
+ continue;
363
+ }
364
+ try {
365
+ return await connectSocket(socketPath, Math.min(250, deadline - Date.now()));
366
+ }
367
+ catch (error) {
368
+ lastError = error;
369
+ await new Promise((resolve) => setTimeout(resolve, 25));
370
+ }
371
+ }
372
+ throw new Error(`Unable to connect to Kibi engine at ${socketPath}: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
373
+ }
374
+ /** PrologPort-compatible client used by CLI and MCP operation runtimes. */
375
+ // implements REQ-core-journaled-engine-persistence
376
+ export class EngineClient {
377
+ workspaceRoot;
378
+ branch;
379
+ timeout;
380
+ socket = null;
381
+ daemon = null;
382
+ inputBuffer = Buffer.alloc(0);
383
+ requestId = 0;
384
+ pending = new Map();
385
+ requestTail = Promise.resolve();
386
+ lastResult = null;
387
+ constructor(options) {
388
+ if (!isValidBranchName(options.branch)) {
389
+ throw new Error(`Invalid Kibi engine branch name: ${options.branch}`);
390
+ }
391
+ const resolvedWorkspaceRoot = path.resolve(options.workspaceRoot);
392
+ try {
393
+ this.workspaceRoot = realpathSync(resolvedWorkspaceRoot);
394
+ }
395
+ catch {
396
+ this.workspaceRoot = resolvedWorkspaceRoot;
397
+ }
398
+ this.branch = options.branch;
399
+ this.timeout = options.timeout ?? 120_000;
400
+ }
401
+ async start(allowSpawn = true) {
402
+ if (this.socket !== null && !this.socket.destroyed)
403
+ return;
404
+ const socketPath = engineSocketPath(this.workspaceRoot, this.branch);
405
+ try {
406
+ if (existsSync(socketPath)) {
407
+ this.socket = await connectWithRetry(socketPath, 150);
408
+ }
409
+ else {
410
+ throw new Error("engine socket is absent");
411
+ }
412
+ }
413
+ catch {
414
+ if (!allowSpawn)
415
+ return;
416
+ const nodePath = process.env.KIBI_NODE_PATH ?? "node";
417
+ const entry = daemonEntry();
418
+ const lockPath = engineStartLockPath(this.workspaceRoot, this.branch);
419
+ const startDeadline = Date.now() + this.timeout;
420
+ let lock = null;
421
+ while (lock === null && Date.now() < startDeadline) {
422
+ if (existsSync(socketPath)) {
423
+ try {
424
+ this.socket = await connectWithRetry(socketPath, 100);
425
+ break;
426
+ }
427
+ catch {
428
+ // The daemon may be replacing a stale socket; retry below.
429
+ }
430
+ }
431
+ if (this.socket === null) {
432
+ lock = tryAcquireStartLock(lockPath);
433
+ if (lock === null) {
434
+ await new Promise((resolve) => setTimeout(resolve, 50));
435
+ }
436
+ }
437
+ }
438
+ if (this.socket === null && lock === null) {
439
+ throw new Error(`Unable to connect to Kibi engine at ${socketPath} while another starter owns ${lockPath}`);
440
+ }
441
+ if (lock !== null) {
442
+ try {
443
+ rmSync(`${socketPath}.error`, { force: true });
444
+ try {
445
+ const daemon = spawn(nodePath, [
446
+ entry,
447
+ "--workspace",
448
+ this.workspaceRoot,
449
+ "--branch",
450
+ this.branch,
451
+ "--socket",
452
+ socketPath,
453
+ ], {
454
+ detached: true,
455
+ stdio: "ignore",
456
+ env: {
457
+ ...process.env,
458
+ KIBI_ENGINE_PROTOCOL: String(ENGINE_PROTOCOL_VERSION),
459
+ },
460
+ });
461
+ await new Promise((resolve, reject) => {
462
+ daemon.once("spawn", resolve);
463
+ daemon.once("error", reject);
464
+ });
465
+ this.daemon = daemon;
466
+ }
467
+ catch (error) {
468
+ throw new Error(`Kibi engine requires Node.js >=18 to host the daemon; failed to start '${nodePath}': ${error instanceof Error ? error.message : String(error)}`);
469
+ }
470
+ this.daemon.unref();
471
+ this.socket = await connectWithRetry(socketPath, this.timeout);
472
+ }
473
+ finally {
474
+ releaseStartLock(lockPath, lock);
475
+ }
476
+ }
477
+ }
478
+ if (this.socket === null)
479
+ return;
480
+ this.attachSocket(this.socket);
481
+ }
482
+ attachSocket(socket) {
483
+ socket.on("data", (chunk) => {
484
+ try {
485
+ const bytes = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
486
+ this.inputBuffer = parseFrames(Buffer.concat([this.inputBuffer, bytes]), (value) => this.handleResponse(value));
487
+ }
488
+ catch (error) {
489
+ socket.destroy(error instanceof Error ? error : new Error(String(error)));
490
+ }
491
+ });
492
+ socket.on("close", () => {
493
+ this.socket = null;
494
+ const error = new Error("Kibi engine connection closed");
495
+ for (const pending of this.pending.values())
496
+ pending.reject(error);
497
+ this.pending.clear();
498
+ });
499
+ socket.on("error", (error) => {
500
+ for (const pending of this.pending.values())
501
+ pending.reject(error);
502
+ this.pending.clear();
503
+ });
504
+ }
505
+ handleResponse(value) {
506
+ if (value === null || typeof value !== "object")
507
+ return;
508
+ const response = value;
509
+ if (typeof response.id !== "number")
510
+ return;
511
+ const pending = this.pending.get(response.id);
512
+ if (!pending)
513
+ return;
514
+ this.pending.delete(response.id);
515
+ if (response.ok)
516
+ pending.resolve(response.result);
517
+ else
518
+ pending.reject(new Error(response.error ?? "Kibi engine request failed"));
519
+ }
520
+ async request(request, signal) {
521
+ await this.start();
522
+ const socket = this.socket;
523
+ if (socket === null || socket.destroyed)
524
+ throw new Error("Kibi engine is not connected");
525
+ const id = ++this.requestId;
526
+ const result = new Promise((resolve, reject) => {
527
+ this.pending.set(id, {
528
+ resolve: resolve,
529
+ reject,
530
+ });
531
+ try {
532
+ socket.write(frame({
533
+ ...request,
534
+ id,
535
+ protocolVersion: ENGINE_PROTOCOL_VERSION,
536
+ packageVersions: ENGINE_PACKAGE_VERSIONS,
537
+ workspaceRoot: this.workspaceRoot,
538
+ branch: this.branch,
539
+ }));
540
+ }
541
+ catch (error) {
542
+ this.pending.delete(id);
543
+ reject(error instanceof Error ? error : new Error(String(error)));
544
+ return;
545
+ }
546
+ if (signal !== undefined) {
547
+ const abort = () => {
548
+ if (!this.pending.has(id))
549
+ return;
550
+ this.pending.delete(id);
551
+ this.cancel(id);
552
+ reject(new Error("Kibi engine request cancelled"));
553
+ };
554
+ if (signal.aborted)
555
+ abort();
556
+ else
557
+ signal.addEventListener("abort", abort, { once: true });
558
+ }
559
+ });
560
+ return result;
561
+ }
562
+ async query(goal, signal) {
563
+ let result;
564
+ const previous = this.requestTail;
565
+ let release;
566
+ this.requestTail = new Promise((resolve) => {
567
+ release = resolve;
568
+ });
569
+ await previous;
570
+ try {
571
+ result = await this.request({ method: "query", goal }, signal);
572
+ this.lastResult = result;
573
+ return result;
574
+ }
575
+ finally {
576
+ release();
577
+ }
578
+ }
579
+ async queryBatch(goals) {
580
+ const normalized = goals.map((goal) => goal.trim().replace(/\.+\s*$/, ""));
581
+ return this.query(`rdf_transaction((${normalized.join(", ")}))`);
582
+ }
583
+ async queryEntities(input) {
584
+ const result = await this.request({
585
+ method: "entities",
586
+ ...(input.type !== undefined ? { type: input.type } : {}),
587
+ ...(input.id !== undefined ? { entityId: input.id } : {}),
588
+ ...(input.tags !== undefined ? { tags: input.tags } : {}),
589
+ ...(input.sourceFile !== undefined
590
+ ? { sourceFile: input.sourceFile }
591
+ : {}),
592
+ limit: input.limit,
593
+ offset: input.offset,
594
+ });
595
+ if (!result.success) {
596
+ throw new Error(result.error ?? "Indexed entity query failed");
597
+ }
598
+ const rows = result.bindings.Rows
599
+ ? parseListOfLists(result.bindings.Rows).map(parseEntityFromList)
600
+ : [];
601
+ const count = Number.parseInt(result.bindings.Count ?? "0", 10);
602
+ return {
603
+ entities: rows,
604
+ count: Number.isFinite(count) ? count : rows.length,
605
+ };
606
+ }
607
+ async searchEntities(input) {
608
+ const result = await this.request({
609
+ method: "search",
610
+ searchQuery: input.query,
611
+ ...(input.type !== undefined ? { type: input.type } : {}),
612
+ limit: input.limit,
613
+ offset: input.offset,
614
+ });
615
+ if (!result.success) {
616
+ throw new Error(result.error ?? "Indexed search candidate query failed");
617
+ }
618
+ const rows = result.bindings.Rows
619
+ ? parseListOfLists(result.bindings.Rows).map(parseEntityFromList)
620
+ : [];
621
+ const count = Number.parseInt(result.bindings.Count ?? "0", 10);
622
+ return {
623
+ entities: rows,
624
+ count: Number.isFinite(count) ? count : rows.length,
625
+ };
626
+ }
627
+ /** Best-effort cancellation for a queued request. */
628
+ cancel(requestId) {
629
+ const socket = this.socket;
630
+ if (socket === null || socket.destroyed)
631
+ return;
632
+ const id = ++this.requestId;
633
+ socket.write(frame({
634
+ id,
635
+ method: "cancel",
636
+ cancelOf: requestId,
637
+ protocolVersion: ENGINE_PROTOCOL_VERSION,
638
+ packageVersions: ENGINE_PACKAGE_VERSIONS,
639
+ workspaceRoot: this.workspaceRoot,
640
+ branch: this.branch,
641
+ }));
642
+ }
643
+ async nextSolution() {
644
+ const result = this.lastResult;
645
+ this.lastResult = null;
646
+ return result;
647
+ }
648
+ async save() {
649
+ return this.query("kb_save");
650
+ }
651
+ async storageStatus() {
652
+ return this.request({ method: "status" });
653
+ }
654
+ async checkpoint() {
655
+ return this.request({ method: "checkpoint" });
656
+ }
657
+ async queryStatusJson() {
658
+ return this.request({ method: "kbStatus" });
659
+ }
660
+ async compact() {
661
+ return this.request({ method: "compact" });
662
+ }
663
+ async exportStorage(targetDirectory) {
664
+ return this.request({
665
+ method: "export",
666
+ targetDirectory,
667
+ });
668
+ }
669
+ invalidateCache() {
670
+ this.lastResult = null;
671
+ }
672
+ isRunning() {
673
+ return this.socket !== null && !this.socket.destroyed;
674
+ }
675
+ getPid() {
676
+ const pidPath = enginePidPath(this.workspaceRoot, this.branch);
677
+ try {
678
+ return Number.parseInt(readFileSync(pidPath, "utf8").trim(), 10) || 0;
679
+ }
680
+ catch {
681
+ return 0;
682
+ }
683
+ }
684
+ async stop(startIfMissing = true) {
685
+ const socketPath = engineSocketPath(this.workspaceRoot, this.branch);
686
+ if (!this.isRunning()) {
687
+ try {
688
+ await this.start(startIfMissing);
689
+ }
690
+ catch {
691
+ if (!startIfMissing)
692
+ return;
693
+ throw new Error("Unable to start Kibi engine for stop request");
694
+ }
695
+ }
696
+ if (!this.isRunning())
697
+ return;
698
+ await this.request({ method: "stop" }).catch(() => undefined);
699
+ this.socket?.destroy();
700
+ this.socket = null;
701
+ // The daemon acknowledges stop before its SWI child has flushed and the
702
+ // listening socket has closed. Do not let a following auto-start attach to
703
+ // that half-shutdown process.
704
+ const deadline = Date.now() + 5_000;
705
+ while (existsSync(socketPath) && Date.now() < deadline) {
706
+ await new Promise((resolve) => setTimeout(resolve, 25));
707
+ }
708
+ }
709
+ async terminate() {
710
+ // Closing a client connection releases the engine; the daemon remains
711
+ // available for other operations until its idle timeout expires.
712
+ this.socket?.end();
713
+ this.socket?.destroy();
714
+ this.socket = null;
715
+ this.daemon = null;
716
+ this.lastResult = null;
717
+ }
718
+ }
719
+ // implements REQ-core-journaled-engine-persistence
720
+ export async function ensureJournaledBranchStoreAsync(branchPath) {
721
+ mkdirSync(branchPath, { recursive: true });
722
+ const markerPath = path.join(branchPath, "storage.json");
723
+ const marker = existsSync(markerPath) ? readFileSync(markerPath, "utf8") : "";
724
+ if (marker.includes("kibi.rdf-journal.v1")) {
725
+ const rdfDirectory = path.join(branchPath, "rdf");
726
+ const currentPath = path.join(branchPath, "CURRENT");
727
+ const sentinelPath = path.join(branchPath, "kb.rdf");
728
+ if (!existsSync(rdfDirectory) || !existsSync(currentPath)) {
729
+ recoverInterruptedGeneration(branchPath);
730
+ if (existsSync(rdfDirectory) && existsSync(currentPath)) {
731
+ recoverStaleJournalLock(branchPath);
732
+ archiveLegacyFiles(branchPath);
733
+ if (!existsSync(sentinelPath)) {
734
+ writeFileSync(sentinelPath, "KIBI_STORAGE_FORMAT=kibi.rdf-journal.v1\n", { mode: 0o600 });
735
+ }
736
+ return;
737
+ }
738
+ // Publication moves the validated generation before archiving the
739
+ // legacy files. If the process dies between those moves, preserve the
740
+ // still-live legacy store and discard only the incomplete publication.
741
+ const legacyRdf = path.join(branchPath, "kb.rdf");
742
+ const legacyAudit = path.join(branchPath, "audit.log");
743
+ const legacyContents = existsSync(legacyRdf)
744
+ ? readFileSync(legacyRdf, "utf8")
745
+ : "";
746
+ if (existsSync(legacyAudit) ||
747
+ (existsSync(legacyRdf) &&
748
+ !legacyContents.includes("kibi.rdf-journal.v1"))) {
749
+ rmSync(rdfDirectory, { recursive: true, force: true });
750
+ rmSync(currentPath, { force: true });
751
+ rmSync(markerPath, { force: true });
752
+ }
753
+ else {
754
+ throw new Error(`Journaled branch store is incomplete at ${branchPath}; restore the immutable legacy/ backup or remove the interrupted generation before retrying`);
755
+ }
756
+ }
757
+ else {
758
+ recoverStaleJournalLock(branchPath);
759
+ // The sentinel is deliberately written last during migration. If it is
760
+ // missing after a crash, archive any remaining legacy files and fence
761
+ // old clients before allowing a new engine to attach.
762
+ archiveLegacyFiles(branchPath);
763
+ if (!existsSync(sentinelPath)) {
764
+ writeFileSync(sentinelPath, "KIBI_STORAGE_FORMAT=kibi.rdf-journal.v1\n", { mode: 0o600 });
765
+ }
766
+ return;
767
+ }
768
+ }
769
+ const legacyRdf = path.join(branchPath, "kb.rdf");
770
+ const legacyAudit = path.join(branchPath, "audit.log");
771
+ if (!existsSync(legacyRdf) && !existsSync(legacyAudit)) {
772
+ mkdirSync(path.join(branchPath, "rdf"), { recursive: true });
773
+ writeFileSync(markerPath, '{"format":"kibi.rdf-journal.v1","schemaVersion":1}\n', { mode: 0o600 });
774
+ writeFileSync(path.join(branchPath, "CURRENT"), "generation-1:0\n", {
775
+ mode: 0o600,
776
+ });
777
+ writeFileSync(path.join(branchPath, "kb.rdf"), "KIBI_STORAGE_FORMAT=kibi.rdf-journal.v1\n", { mode: 0o600 });
778
+ return;
779
+ }
780
+ if (existsSync(legacyRdf)) {
781
+ const legacyContents = readFileSync(legacyRdf, "utf8");
782
+ if (!/<rdf:RDF(?:\s|>)/.test(legacyContents)) {
783
+ throw new Error(`Legacy Kibi store is corrupt at ${legacyRdf}: expected RDF/XML root`);
784
+ }
785
+ }
786
+ const parent = path.dirname(branchPath);
787
+ const migrationKey = createHash("sha256")
788
+ .update(path.resolve(branchPath))
789
+ .digest("hex")
790
+ .slice(0, 16);
791
+ const staging = path.join(parent, `.${migrationKey}.migration.${process.pid}.${Date.now()}`);
792
+ mkdirSync(staging, { recursive: true, mode: 0o700 });
793
+ // Production migration runs in the Node daemon. The one-shot branch exists
794
+ // solely for repository tests that import this helper under Bun.
795
+ const testOneShot = process.env.NODE_ENV === "test" &&
796
+ typeof globalThis.Bun !== "undefined";
797
+ const migrator = new PrologProcess({
798
+ timeout: 120_000,
799
+ oneShot: testOneShot,
800
+ });
801
+ try {
802
+ if (!testOneShot)
803
+ await migrator.start();
804
+ const attachGoal = `kb_attach('${quoteProlog(branchPath)}')`;
805
+ const migrateGoal = `kb_migrate_legacy('${quoteProlog(branchPath)}','${quoteProlog(staging)}')`;
806
+ const migrated = testOneShot
807
+ ? await migrator.query(`(${attachGoal}, ${migrateGoal})`)
808
+ : await (async () => {
809
+ const attach = await migrator.query(attachGoal);
810
+ if (!attach.success)
811
+ throw new Error(attach.error ?? "Failed to attach legacy KB");
812
+ return migrator.query(migrateGoal);
813
+ })();
814
+ if (!migrated.success)
815
+ throw new Error(migrated.error ?? "Legacy KB migration failed");
816
+ fsyncJournaledBranchStore(staging);
817
+ }
818
+ catch (error) {
819
+ rmSync(staging, { recursive: true, force: true });
820
+ throw error;
821
+ }
822
+ finally {
823
+ await migrator.terminate();
824
+ }
825
+ // Publish the validated generation while the originals are still present.
826
+ // A crash before the marker is moved leaves the legacy branch readable; a
827
+ // crash after it is moved is repaired by the marker branch above.
828
+ for (const file of ["rdf", "storage.json", "CURRENT"]) {
829
+ const source = path.join(staging, file);
830
+ if (!existsSync(source))
831
+ continue;
832
+ const target = path.join(branchPath, file);
833
+ if (existsSync(target))
834
+ rmSync(target, { recursive: true, force: true });
835
+ renameSync(source, target);
836
+ }
837
+ archiveLegacyFiles(branchPath);
838
+ // Older clients must fail closed instead of seeing an empty branch.
839
+ writeFileSync(path.join(branchPath, "kb.rdf"), "KIBI_STORAGE_FORMAT=kibi.rdf-journal.v1\n", { mode: 0o600 });
840
+ fsyncJournaledBranchStore(branchPath);
841
+ rmSync(staging, { recursive: true, force: true });
842
+ }
843
+ function archiveLegacyFiles(branchPath) {
844
+ const legacyDir = path.join(branchPath, "legacy");
845
+ mkdirSync(legacyDir, { recursive: true, mode: 0o700 });
846
+ for (const file of ["kb.rdf", "audit.log"]) {
847
+ const source = path.join(branchPath, file);
848
+ if (!existsSync(source))
849
+ continue;
850
+ if (file === "kb.rdf") {
851
+ const contents = readFileSync(source, "utf8");
852
+ if (contents.includes("kibi.rdf-journal.v1"))
853
+ continue;
854
+ }
855
+ const destination = path.join(legacyDir, file);
856
+ if (existsSync(destination)) {
857
+ // Never overwrite an immutable backup from an earlier interrupted
858
+ // attempt. Preserve the second copy with an explicit recovery suffix.
859
+ renameSync(source, path.join(legacyDir, `${file}.recovery.${Date.now()}`));
860
+ }
861
+ else {
862
+ renameSync(source, destination);
863
+ }
864
+ }
865
+ }
866
+ function recoverInterruptedGeneration(branchPath) {
867
+ let entries = [];
868
+ try {
869
+ entries = readdirSync(branchPath);
870
+ }
871
+ catch {
872
+ return;
873
+ }
874
+ const newest = (prefix) => {
875
+ const candidates = entries
876
+ .filter((entry) => entry.startsWith(prefix))
877
+ .sort()
878
+ .reverse();
879
+ const candidate = candidates[0];
880
+ return candidate === undefined ? null : path.join(branchPath, candidate);
881
+ };
882
+ const oldRdf = newest("rdf.old.");
883
+ const oldCurrent = newest("CURRENT.old.");
884
+ const rdfPath = path.join(branchPath, "rdf");
885
+ const currentPath = path.join(branchPath, "CURRENT");
886
+ // If either half of the pointer/database pair is missing, prefer restoring
887
+ // the matching old pair. This prevents a new RDF generation from being
888
+ // opened with the old commit sequence after a crash between renames.
889
+ if ((!existsSync(rdfPath) || !existsSync(currentPath)) &&
890
+ oldRdf &&
891
+ oldCurrent) {
892
+ try {
893
+ if (existsSync(rdfPath))
894
+ rmSync(rdfPath, { recursive: true, force: true });
895
+ if (existsSync(currentPath))
896
+ rmSync(currentPath, { force: true });
897
+ renameSync(oldRdf, rdfPath);
898
+ renameSync(oldCurrent, currentPath);
899
+ return;
900
+ }
901
+ catch {
902
+ // Fall through to the individual best-effort restores below.
903
+ }
904
+ }
905
+ if (!existsSync(rdfPath) && oldRdf) {
906
+ try {
907
+ renameSync(oldRdf, rdfPath);
908
+ }
909
+ catch {
910
+ // Leave the actionable incomplete-store error for the caller.
911
+ }
912
+ }
913
+ if (!existsSync(currentPath) && oldCurrent) {
914
+ try {
915
+ renameSync(oldCurrent, currentPath);
916
+ }
917
+ catch {
918
+ // Leave the actionable incomplete-store error for the caller.
919
+ }
920
+ }
921
+ }
922
+ // implements REQ-core-journaled-engine-lifecycle
923
+ export async function runEngineDaemon(options) {
924
+ if (!isValidBranchName(options.branch)) {
925
+ throw new Error(`Invalid Kibi engine branch name: ${options.branch}`);
926
+ }
927
+ await ensureJournaledBranchStoreAsync(path.join(options.workspaceRoot, ".kb", "branches", options.branch));
928
+ const prolog = new PrologProcess({ timeout: 120_000, oneShot: false });
929
+ await prolog.start();
930
+ const branchPath = path.join(options.workspaceRoot, ".kb", "branches", options.branch);
931
+ const attached = await prolog.query(`kb_attach('${quoteProlog(branchPath)}')`);
932
+ if (!attached.success)
933
+ throw new Error(attached.error ?? "Failed to attach branch KB");
934
+ const statusModulePath = path.join(path.dirname(resolveKbPlPath()), "status.pl");
935
+ const statusLoaded = await prolog.query(`use_module('${quoteProlog(statusModulePath.replaceAll("\\", "/"))}')`);
936
+ if (!statusLoaded.success) {
937
+ throw new Error(statusLoaded.error ?? "Failed to load Kibi status module");
938
+ }
939
+ mkdirSync(path.dirname(options.socketPath), { recursive: true, mode: 0o700 });
940
+ if (existsSync(options.socketPath)) {
941
+ let live = false;
942
+ try {
943
+ const existing = await connectSocket(options.socketPath, 100);
944
+ existing.destroy();
945
+ live = true;
946
+ }
947
+ catch {
948
+ // A refused connection means this is a stale filesystem socket.
949
+ }
950
+ if (live) {
951
+ throw new Error(`A Kibi engine is already listening at ${options.socketPath}`);
952
+ }
953
+ try {
954
+ rmSync(options.socketPath, { force: true });
955
+ }
956
+ catch {
957
+ // The listen call below reports permission/address-in-use failures.
958
+ }
959
+ }
960
+ writeFileSync(enginePidPath(options.workspaceRoot, options.branch), `${process.pid}\n`, { mode: 0o600 });
961
+ const server = net.createServer();
962
+ let activeClients = 0;
963
+ let idleTimer = null;
964
+ let queue = Promise.resolve();
965
+ let idleCompactionQueued = false;
966
+ const queryCache = new Map();
967
+ let freshnessCache = null;
968
+ const cancelledRequests = new Set();
969
+ const clients = new Set();
970
+ let shuttingDown = false;
971
+ const scheduleIdleExit = () => {
972
+ if (activeClients > 0)
973
+ return;
974
+ if (!idleCompactionQueued) {
975
+ idleCompactionQueued = true;
976
+ queue = queue
977
+ .then(async () => {
978
+ if (activeClients === 0) {
979
+ await prolog.query("kb_storage_compact_if_needed");
980
+ }
981
+ })
982
+ .catch(() => undefined)
983
+ .finally(() => {
984
+ idleCompactionQueued = false;
985
+ });
986
+ }
987
+ if (idleTimer)
988
+ clearTimeout(idleTimer);
989
+ idleTimer = setTimeout(() => {
990
+ if (activeClients === 0)
991
+ void shutdown();
992
+ }, engineIdleTimeoutMs());
993
+ };
994
+ const handle = async (request) => {
995
+ if (request.protocolVersion !== ENGINE_PROTOCOL_VERSION) {
996
+ throw new Error(`Kibi engine protocol mismatch: client=${request.protocolVersion ?? "missing"}, server=${ENGINE_PROTOCOL_VERSION}`);
997
+ }
998
+ const expectedPackages = process.env.KIBI_PACKAGE_VERSIONS;
999
+ if (expectedPackages !== undefined &&
1000
+ request.packageVersions !== expectedPackages) {
1001
+ throw new Error(`Kibi engine package-version mismatch: client=${request.packageVersions ?? "missing"}, server=${expectedPackages}`);
1002
+ }
1003
+ if (path.resolve(request.workspaceRoot ?? "") !==
1004
+ path.resolve(options.workspaceRoot) ||
1005
+ request.branch !== options.branch) {
1006
+ throw new Error("Kibi engine workspace identity mismatch");
1007
+ }
1008
+ switch (request.method) {
1009
+ case "entities": {
1010
+ const limit = request.limit ?? 100;
1011
+ const offset = request.offset ?? 0;
1012
+ if (!Number.isInteger(limit) ||
1013
+ !Number.isInteger(offset) ||
1014
+ limit < 0 ||
1015
+ offset < 0 ||
1016
+ limit > 100_000) {
1017
+ throw new Error("entities.limit/offset must be bounded integers");
1018
+ }
1019
+ const type = request.type === undefined
1020
+ ? "none"
1021
+ : `'${quoteProlog(request.type)}'`;
1022
+ const id = request.entityId === undefined
1023
+ ? "none"
1024
+ : `'${quoteProlog(request.entityId)}'`;
1025
+ const tags = `[${(request.tags ?? [])
1026
+ .map((tag) => `'${quoteProlog(tag)}'`)
1027
+ .join(",")}]`;
1028
+ const source = request.sourceFile === undefined
1029
+ ? "none"
1030
+ : `'${quoteProlog(request.sourceFile)}'`;
1031
+ const result = await prolog.query(`kb_query_entities(${type}, ${id}, ${tags}, ${source}, ${limit}, ${offset}, Rows, Count)`);
1032
+ if (!result.success) {
1033
+ throw new Error(result.error ?? "Indexed entity query failed");
1034
+ }
1035
+ return result;
1036
+ }
1037
+ case "search": {
1038
+ const limit = request.limit ?? 10_000;
1039
+ const offset = request.offset ?? 0;
1040
+ if (typeof request.searchQuery !== "string" ||
1041
+ request.searchQuery.trim().length === 0) {
1042
+ throw new Error("search.searchQuery must be a non-empty string");
1043
+ }
1044
+ if (!Number.isInteger(limit) ||
1045
+ !Number.isInteger(offset) ||
1046
+ limit < 0 ||
1047
+ offset < 0 ||
1048
+ limit > 100_000) {
1049
+ throw new Error("search.limit/offset must be bounded integers");
1050
+ }
1051
+ const type = request.type === undefined
1052
+ ? "none"
1053
+ : `'${quoteProlog(request.type)}'`;
1054
+ const result = await prolog.query(`kb_search_entities(${type}, '${quoteProlog(request.searchQuery)}', ${limit}, ${offset}, Rows, Count)`);
1055
+ if (!result.success) {
1056
+ throw new Error(result.error ?? "Indexed search candidate query failed");
1057
+ }
1058
+ return result;
1059
+ }
1060
+ case "kbStatus": {
1061
+ if (freshnessCache !== null &&
1062
+ Date.now() - freshnessCache.capturedAt <= ENGINE_FRESHNESS_CACHE_MS) {
1063
+ return freshnessCache.result;
1064
+ }
1065
+ const result = await prolog.query("status:kb_status_json(JsonString)");
1066
+ if (result.success) {
1067
+ freshnessCache = { capturedAt: Date.now(), result };
1068
+ }
1069
+ return result;
1070
+ }
1071
+ case "query": {
1072
+ if (typeof request.goal !== "string")
1073
+ throw new Error("query.goal must be a string");
1074
+ if (!safeEngineGoal(request.goal)) {
1075
+ throw new Error("Kibi engine accepts only typed Kibi predicates; arbitrary Prolog goals are not exposed");
1076
+ }
1077
+ if (mutatingEngineGoal(request.goal)) {
1078
+ queryCache.clear();
1079
+ freshnessCache = null;
1080
+ }
1081
+ if (cacheableEngineGoal(request.goal)) {
1082
+ const cached = queryCache.get(request.goal);
1083
+ if (cached !== undefined)
1084
+ return cached;
1085
+ }
1086
+ const result = await prolog.query(request.goal);
1087
+ if (result.success && mutatingEngineGoal(request.goal)) {
1088
+ fsyncJournaledBranchStore(branchPath);
1089
+ }
1090
+ if (result.success &&
1091
+ cacheableEngineGoal(request.goal) &&
1092
+ Buffer.byteLength(JSON.stringify(result), "utf8") <=
1093
+ ENGINE_QUERY_CACHE_MAX_RESULT_BYTES) {
1094
+ if (queryCache.size >= ENGINE_QUERY_CACHE_MAX_ENTRIES) {
1095
+ const oldest = queryCache.keys().next().value;
1096
+ if (typeof oldest === "string")
1097
+ queryCache.delete(oldest);
1098
+ }
1099
+ queryCache.set(request.goal, result);
1100
+ }
1101
+ return result;
1102
+ }
1103
+ case "status":
1104
+ return prolog.query("kb_storage_status(Status), atom_json_dict(Json, Status, [])");
1105
+ case "checkpoint": {
1106
+ queryCache.clear();
1107
+ freshnessCache = null;
1108
+ const result = await prolog.query("kb_sync_checkpoint");
1109
+ if (result.success)
1110
+ fsyncJournaledBranchStore(branchPath);
1111
+ return result;
1112
+ }
1113
+ case "compact": {
1114
+ const result = await prolog.query("kb_storage_compact");
1115
+ if (result.success)
1116
+ fsyncJournaledBranchStore(branchPath);
1117
+ return result;
1118
+ }
1119
+ case "export":
1120
+ if (typeof request.targetDirectory !== "string")
1121
+ throw new Error("export.targetDirectory is required");
1122
+ return prolog.query(`kb_storage_export('${quoteProlog(request.targetDirectory)}')`);
1123
+ case "stop":
1124
+ setImmediate(() => void shutdown());
1125
+ return { stopped: true };
1126
+ case "cancel":
1127
+ if (typeof request.cancelOf === "number") {
1128
+ cancelledRequests.add(request.cancelOf);
1129
+ }
1130
+ return { cancelled: request.cancelOf ?? null };
1131
+ }
1132
+ };
1133
+ const shutdown = async () => {
1134
+ if (shuttingDown)
1135
+ return;
1136
+ shuttingDown = true;
1137
+ process.off("SIGTERM", requestSignalShutdown);
1138
+ process.off("SIGINT", requestSignalShutdown);
1139
+ if (idleTimer)
1140
+ clearTimeout(idleTimer);
1141
+ const saved = await prolog.query("kb_save").catch(() => null);
1142
+ if (saved?.success) {
1143
+ try {
1144
+ fsyncJournaledBranchStore(branchPath);
1145
+ }
1146
+ catch {
1147
+ // Shutdown remains best effort. Request-time writes fail closed if
1148
+ // their durability barrier cannot complete.
1149
+ }
1150
+ }
1151
+ await prolog.terminate().catch(() => undefined);
1152
+ for (const client of clients)
1153
+ client.destroy();
1154
+ server.close();
1155
+ // A close callback can remain pending on older Node releases when a
1156
+ // client disconnects during `server.close()`. The daemon has already
1157
+ // removed its socket/pid and has no useful work left, so do not keep the
1158
+ // hosting process alive on that bookkeeping handle.
1159
+ server.unref();
1160
+ try {
1161
+ if (existsSync(options.socketPath))
1162
+ rmSync(options.socketPath, { force: true });
1163
+ if (existsSync(enginePidPath(options.workspaceRoot, options.branch)))
1164
+ rmSync(enginePidPath(options.workspaceRoot, options.branch), {
1165
+ force: true,
1166
+ });
1167
+ }
1168
+ catch {
1169
+ // best effort cleanup
1170
+ }
1171
+ // The daemon is a detached host process. Once the SWI child and socket are
1172
+ // gone, force the final event-loop exit rather than retaining a stale
1173
+ // detached Node process on platform-specific server bookkeeping.
1174
+ setTimeout(() => process.exit(0), 50).unref();
1175
+ };
1176
+ // Detached engines must cross the same durability boundary when a service
1177
+ // manager or a test harness terminates them as they do for an RPC stop.
1178
+ // implements REQ-test-journaled-engine-harness
1179
+ const requestSignalShutdown = () => {
1180
+ void shutdown();
1181
+ };
1182
+ process.once("SIGTERM", requestSignalShutdown);
1183
+ process.once("SIGINT", requestSignalShutdown);
1184
+ server.on("connection", (socket) => {
1185
+ clients.add(socket);
1186
+ activeClients += 1;
1187
+ let buffer = Buffer.alloc(0);
1188
+ socket.on("data", (chunk) => {
1189
+ try {
1190
+ const bytes = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
1191
+ buffer = parseFrames(Buffer.concat([buffer, bytes]), (value) => {
1192
+ const request = value;
1193
+ if (request.method === "cancel" &&
1194
+ typeof request.cancelOf === "number") {
1195
+ cancelledRequests.add(request.cancelOf);
1196
+ }
1197
+ queue = queue
1198
+ .then(async () => {
1199
+ if (cancelledRequests.delete(request.id)) {
1200
+ writeSocketFrame(socket, {
1201
+ id: request.id,
1202
+ ok: false,
1203
+ error: "Kibi engine request cancelled",
1204
+ serverPackageVersions: ENGINE_PACKAGE_VERSIONS,
1205
+ });
1206
+ return;
1207
+ }
1208
+ try {
1209
+ const result = await handle(request);
1210
+ writeSocketFrame(socket, {
1211
+ id: request.id,
1212
+ ok: true,
1213
+ result,
1214
+ serverPackageVersions: ENGINE_PACKAGE_VERSIONS,
1215
+ });
1216
+ }
1217
+ catch (error) {
1218
+ writeSocketFrame(socket, {
1219
+ id: request.id,
1220
+ ok: false,
1221
+ error: error instanceof Error ? error.message : String(error),
1222
+ serverPackageVersions: ENGINE_PACKAGE_VERSIONS,
1223
+ });
1224
+ }
1225
+ })
1226
+ .catch(() => undefined);
1227
+ });
1228
+ }
1229
+ catch (error) {
1230
+ socket.destroy(error instanceof Error ? error : new Error(String(error)));
1231
+ }
1232
+ });
1233
+ socket.on("close", () => {
1234
+ clients.delete(socket);
1235
+ activeClients -= 1;
1236
+ scheduleIdleExit();
1237
+ });
1238
+ });
1239
+ await new Promise((resolve, reject) => {
1240
+ server.once("error", reject);
1241
+ server.listen(options.socketPath, () => resolve());
1242
+ });
1243
+ if (process.platform !== "win32") {
1244
+ try {
1245
+ chmodSync(options.socketPath, 0o600);
1246
+ }
1247
+ catch {
1248
+ // The runtime directory is private; retain a usable socket if a
1249
+ // platform does not expose chmod for its local socket implementation.
1250
+ }
1251
+ }
1252
+ scheduleIdleExit();
1253
+ await new Promise((resolve) => {
1254
+ server.once("close", resolve);
1255
+ });
1256
+ }