shabti 2.4.0 → 2.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shabti",
3
- "version": "2.4.0",
3
+ "version": "2.6.0",
4
4
  "description": "Agent Memory OS — semantic memory for AI agents",
5
5
  "type": "module",
6
6
  "main": "native.cjs",
@@ -0,0 +1,48 @@
1
+ import { readFileSync } from "fs";
2
+
3
+ const { version } = JSON.parse(
4
+ readFileSync(new URL("../../package.json", import.meta.url), "utf8"),
5
+ );
6
+
7
+ export function buildAgentCard(baseUrl) {
8
+ return {
9
+ protocolVersion: "0.3.0",
10
+ name: "Shabti Memory Engine",
11
+ description: "A2A-compatible memory agent providing storage, search, and status operations",
12
+ url: baseUrl,
13
+ version,
14
+ capabilities: {
15
+ streaming: false,
16
+ pushNotifications: false,
17
+ stateTransitionHistory: false,
18
+ },
19
+ defaultInputModes: ["text", "application/json"],
20
+ defaultOutputModes: ["application/json"],
21
+ skills: [
22
+ {
23
+ id: "memory_store",
24
+ name: "Store Memory",
25
+ description: "Persist a memory entry with content and optional metadata tags",
26
+ tags: ["memory", "store", "save", "remember"],
27
+ inputModes: ["text", "application/json"],
28
+ outputModes: ["application/json"],
29
+ },
30
+ {
31
+ id: "memory_search",
32
+ name: "Search Memories",
33
+ description: "Search stored memories using semantic similarity queries",
34
+ tags: ["memory", "search", "recall", "find", "query"],
35
+ inputModes: ["text", "application/json"],
36
+ outputModes: ["application/json"],
37
+ },
38
+ {
39
+ id: "memory_status",
40
+ name: "Memory Status",
41
+ description: "Get status information about the memory engine",
42
+ tags: ["memory", "status", "health", "stats"],
43
+ inputModes: ["text"],
44
+ outputModes: ["application/json"],
45
+ },
46
+ ],
47
+ };
48
+ }
@@ -0,0 +1,390 @@
1
+ import { createServer } from "http";
2
+ import { randomUUID } from "crypto";
3
+ import { buildAgentCard } from "./agentCard.js";
4
+ import { createEngine } from "../core/engine.js";
5
+ import { logger } from "../utils/logger.js";
6
+
7
+ // ============================================================
8
+ // Task Store (in-memory)
9
+ // ============================================================
10
+
11
+ export class TaskStore {
12
+ constructor() {
13
+ this.tasks = new Map();
14
+ }
15
+
16
+ create(contextId) {
17
+ const task = {
18
+ kind: "task",
19
+ id: randomUUID(),
20
+ contextId: contextId || randomUUID(),
21
+ status: { state: "submitted", timestamp: new Date().toISOString() },
22
+ artifacts: [],
23
+ history: [],
24
+ };
25
+ this.tasks.set(task.id, task);
26
+ return task;
27
+ }
28
+
29
+ get(id) {
30
+ return this.tasks.get(id) || null;
31
+ }
32
+
33
+ set(id, task) {
34
+ this.tasks.set(id, task);
35
+ }
36
+
37
+ clear() {
38
+ this.tasks.clear();
39
+ }
40
+ }
41
+
42
+ // ============================================================
43
+ // Skill Handlers
44
+ // ============================================================
45
+
46
+ async function handleMemoryStore(engine, parts) {
47
+ let content = null;
48
+ let opts = {};
49
+
50
+ for (const part of parts) {
51
+ if (part.kind === "data" && part.data) {
52
+ content = part.data.content || content;
53
+ if (part.data.namespace) opts.namespace = part.data.namespace;
54
+ if (part.data.tags) opts.tags = part.data.tags;
55
+ if (part.data.ttl) opts.ttlSeconds = part.data.ttl;
56
+ } else if (part.kind === "text" && part.text) {
57
+ content = content || part.text;
58
+ }
59
+ }
60
+
61
+ if (!content) {
62
+ throw Object.assign(new Error("Missing content in message parts"), { code: -32602 });
63
+ }
64
+
65
+ const result = await engine.store(content, opts);
66
+ return {
67
+ artifactId: randomUUID(),
68
+ name: "store_result",
69
+ parts: [{ kind: "data", data: { status: result.status, id: result.id || result.existingId } }],
70
+ };
71
+ }
72
+
73
+ async function handleMemorySearch(engine, parts) {
74
+ let query = null;
75
+ let limit = 10;
76
+
77
+ for (const part of parts) {
78
+ if (part.kind === "data" && part.data) {
79
+ query = part.data.query || query;
80
+ if (part.data.limit) limit = part.data.limit;
81
+ } else if (part.kind === "text" && part.text) {
82
+ query = query || part.text;
83
+ }
84
+ }
85
+
86
+ if (!query) {
87
+ throw Object.assign(new Error("Missing query in message parts"), { code: -32602 });
88
+ }
89
+
90
+ const results = await engine.executeQuery({ text: query, limit });
91
+ const formatted = results.map((r) => ({
92
+ id: r.id,
93
+ content: r.content,
94
+ score: r.score,
95
+ namespace: r.namespace,
96
+ }));
97
+
98
+ return {
99
+ artifactId: randomUUID(),
100
+ name: "search_results",
101
+ parts: [{ kind: "data", data: { query, results: formatted, count: formatted.length } }],
102
+ };
103
+ }
104
+
105
+ function handleMemoryStatus(engine) {
106
+ const status = engine.status();
107
+ return {
108
+ artifactId: randomUUID(),
109
+ name: "status",
110
+ parts: [
111
+ {
112
+ kind: "data",
113
+ data: {
114
+ status: "ok",
115
+ entry_count: status.entryCount,
116
+ tier: status.tier,
117
+ model_id: status.modelId,
118
+ },
119
+ },
120
+ ],
121
+ };
122
+ }
123
+
124
+ // ============================================================
125
+ // Skill Router
126
+ // ============================================================
127
+
128
+ export function resolveSkill(parts) {
129
+ // Check for explicit skill in data parts
130
+ for (const part of parts) {
131
+ if (part.kind === "data" && part.data?.skill) {
132
+ return part.data.skill;
133
+ }
134
+ }
135
+
136
+ // Text-based routing
137
+ for (const part of parts) {
138
+ if (part.kind === "text" && part.text) {
139
+ const lower = part.text.toLowerCase();
140
+ if (/\b(store|save|remember)\b/.test(lower)) return "memory_store";
141
+ if (/\b(search|find|recall|query)\b/.test(lower)) return "memory_search";
142
+ if (/\b(status|health|stats|info)\b/.test(lower)) return "memory_status";
143
+ }
144
+ }
145
+
146
+ // Check for structured data that implies a skill
147
+ for (const part of parts) {
148
+ if (part.kind === "data" && part.data) {
149
+ if (part.data.content) return "memory_store";
150
+ if (part.data.query) return "memory_search";
151
+ }
152
+ }
153
+
154
+ return null;
155
+ }
156
+
157
+ // ============================================================
158
+ // JSON-RPC Dispatcher (testable without HTTP)
159
+ // ============================================================
160
+
161
+ export async function dispatchRpc(engine, taskStore, method, params) {
162
+ switch (method) {
163
+ case "message/send":
164
+ return handleMessageSend(engine, taskStore, params || {});
165
+ case "tasks/get":
166
+ return handleTasksGet(taskStore, params || {});
167
+ case "tasks/cancel":
168
+ return handleTasksCancel(taskStore, params || {});
169
+ default:
170
+ return { error: { code: -32601, message: `Method not found: ${method}` } };
171
+ }
172
+ }
173
+
174
+ async function handleMessageSend(engine, taskStore, params) {
175
+ const message = params?.message;
176
+ if (!message || !message.parts || message.parts.length === 0) {
177
+ return { error: { code: -32602, message: "Missing message or parts" } };
178
+ }
179
+
180
+ const skill = resolveSkill(message.parts);
181
+ if (!skill) {
182
+ return {
183
+ error: { code: -32005, message: "Could not determine which skill to invoke from message" },
184
+ };
185
+ }
186
+
187
+ const task = taskStore.create(message.contextId);
188
+
189
+ // Store the incoming message in history
190
+ task.history.push(message);
191
+
192
+ try {
193
+ task.status = { state: "working", timestamp: new Date().toISOString() };
194
+
195
+ let artifact;
196
+ switch (skill) {
197
+ case "memory_store":
198
+ artifact = await handleMemoryStore(engine, message.parts);
199
+ break;
200
+ case "memory_search":
201
+ artifact = await handleMemorySearch(engine, message.parts);
202
+ break;
203
+ case "memory_status":
204
+ artifact = handleMemoryStatus(engine);
205
+ break;
206
+ default:
207
+ task.status = { state: "failed", timestamp: new Date().toISOString() };
208
+ taskStore.set(task.id, task);
209
+ return { error: { code: -32004, message: `Unknown skill: ${skill}` } };
210
+ }
211
+
212
+ task.artifacts = [artifact];
213
+ task.status = { state: "completed", timestamp: new Date().toISOString() };
214
+ } catch (err) {
215
+ task.status = {
216
+ state: "failed",
217
+ timestamp: new Date().toISOString(),
218
+ message: {
219
+ kind: "message",
220
+ role: "agent",
221
+ messageId: randomUUID(),
222
+ parts: [{ kind: "text", text: err.message }],
223
+ },
224
+ };
225
+ taskStore.set(task.id, task);
226
+ if (err.code) return { error: { code: err.code, message: err.message } };
227
+ return { error: { code: -32603, message: err.message } };
228
+ }
229
+
230
+ taskStore.set(task.id, task);
231
+ return { result: task };
232
+ }
233
+
234
+ function handleTasksGet(taskStore, params) {
235
+ const id = params?.id;
236
+ if (!id) return { error: { code: -32602, message: "Missing task id" } };
237
+
238
+ const task = taskStore.get(id);
239
+ if (!task) return { error: { code: -32001, message: `Task not found: ${id}` } };
240
+
241
+ // Apply historyLength filter
242
+ if (params.historyLength !== undefined) {
243
+ const result = { ...task };
244
+ const n = params.historyLength;
245
+ result.history = n === 0 ? [] : task.history.slice(-n);
246
+ return { result };
247
+ }
248
+
249
+ return { result: task };
250
+ }
251
+
252
+ function handleTasksCancel(taskStore, params) {
253
+ const id = params?.id;
254
+ if (!id) return { error: { code: -32602, message: "Missing task id" } };
255
+
256
+ const task = taskStore.get(id);
257
+ if (!task) return { error: { code: -32001, message: `Task not found: ${id}` } };
258
+
259
+ const terminal = ["completed", "failed", "canceled", "rejected"];
260
+ if (terminal.includes(task.status.state)) {
261
+ return {
262
+ error: { code: -32002, message: `Task is already in terminal state: ${task.status.state}` },
263
+ };
264
+ }
265
+
266
+ task.status = { state: "canceled", timestamp: new Date().toISOString() };
267
+ taskStore.set(task.id, task);
268
+ return { result: task };
269
+ }
270
+
271
+ // ============================================================
272
+ // HTTP Server
273
+ // ============================================================
274
+
275
+ function readBody(req) {
276
+ return new Promise((resolve, reject) => {
277
+ const chunks = [];
278
+ req.on("data", (c) => chunks.push(c));
279
+ req.on("end", () => resolve(Buffer.concat(chunks).toString()));
280
+ req.on("error", reject);
281
+ });
282
+ }
283
+
284
+ export function startA2AServer(port = 3000) {
285
+ let engine = null;
286
+ let engineError = null;
287
+
288
+ function getEngine() {
289
+ if (engine) return engine;
290
+ if (engineError) throw engineError;
291
+ try {
292
+ engine = createEngine();
293
+ logger.info("A2A engine initialized");
294
+ } catch (err) {
295
+ logger.error("A2A engine initialization failed", { error: err.message });
296
+ engineError = err;
297
+ throw err;
298
+ }
299
+ return engine;
300
+ }
301
+
302
+ const baseUrl = `http://localhost:${port}/`;
303
+ const agentCard = buildAgentCard(baseUrl);
304
+ const taskStore = new TaskStore();
305
+
306
+ const server = createServer(async (req, res) => {
307
+ // CORS headers
308
+ res.setHeader("Access-Control-Allow-Origin", "*");
309
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
310
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
311
+
312
+ if (req.method === "OPTIONS") {
313
+ res.writeHead(204);
314
+ return res.end();
315
+ }
316
+
317
+ // Agent Card discovery
318
+ if (req.method === "GET" && req.url === "/.well-known/agent-card.json") {
319
+ res.writeHead(200, { "Content-Type": "application/json" });
320
+ return res.end(JSON.stringify(agentCard, null, 2));
321
+ }
322
+
323
+ // JSON-RPC endpoint
324
+ if (req.method === "POST" && (req.url === "/" || req.url === "")) {
325
+ let body;
326
+ try {
327
+ body = await readBody(req);
328
+ } catch {
329
+ res.writeHead(400);
330
+ return res.end();
331
+ }
332
+
333
+ let rpc;
334
+ try {
335
+ rpc = JSON.parse(body);
336
+ } catch {
337
+ res.writeHead(200, { "Content-Type": "application/json" });
338
+ return res.end(
339
+ JSON.stringify({
340
+ jsonrpc: "2.0",
341
+ id: null,
342
+ error: { code: -32700, message: "Parse error" },
343
+ }),
344
+ );
345
+ }
346
+
347
+ const { id, method, params } = rpc;
348
+ let response;
349
+
350
+ try {
351
+ response = await dispatchRpc(getEngine(), taskStore, method, params);
352
+ } catch (err) {
353
+ response = { error: { code: -32603, message: `Engine error: ${err.message}` } };
354
+ }
355
+
356
+ res.writeHead(200, { "Content-Type": "application/json" });
357
+ if (response.error) {
358
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, error: response.error }));
359
+ }
360
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, result: response.result }));
361
+ }
362
+
363
+ res.writeHead(404);
364
+ res.end("Not Found");
365
+ });
366
+
367
+ server.listen(port, "127.0.0.1", () => {
368
+ console.log(`\n Shabti A2A server listening on ${baseUrl}`);
369
+ console.log(` Agent Card: ${baseUrl}.well-known/agent-card.json`);
370
+ console.log(` Skills: ${agentCard.skills.map((s) => s.id).join(", ")}\n`);
371
+ });
372
+
373
+ async function shutdown() {
374
+ console.log("\n Shutting down A2A server...");
375
+ server.close();
376
+ if (engine && engine.shutdown) {
377
+ try {
378
+ await engine.shutdown();
379
+ } catch {
380
+ // best-effort
381
+ }
382
+ }
383
+ process.exit(0);
384
+ }
385
+
386
+ process.on("SIGINT", shutdown);
387
+ process.on("SIGTERM", shutdown);
388
+
389
+ return server;
390
+ }
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Standalone A2A server entry point.
4
+ * Used by tests and `shabti a2a` CLI command.
5
+ */
6
+ import { startA2AServer } from "./server.js";
7
+
8
+ const port = parseInt(process.env.SHABTI_A2A_PORT || "3000", 10);
9
+ startA2AServer(port);
@@ -0,0 +1,19 @@
1
+ import { startA2AServer } from "../a2a/server.js";
2
+ import { error } from "../utils/style.js";
3
+ import { parsePort } from "../utils/validate.js";
4
+
5
+ export function registerA2A(program) {
6
+ program
7
+ .command("a2a")
8
+ .description("Start the A2A (Agent-to-Agent) protocol server")
9
+ .option("-p, --port <port>", "Port to listen on", "3000")
10
+ .action((opts) => {
11
+ try {
12
+ const port = parsePort(opts.port, "--port");
13
+ startA2AServer(port);
14
+ } catch (err) {
15
+ error(err.message);
16
+ process.exitCode = 1;
17
+ }
18
+ });
19
+ }
@@ -1,6 +1,7 @@
1
1
  import { writeFileSync } from "fs";
2
2
  import { createEngine } from "../core/engine.js";
3
3
  import { success, error } from "../utils/style.js";
4
+ import { parsePositiveInt } from "../utils/validate.js";
4
5
 
5
6
  export function registerExport(program) {
6
7
  program
@@ -15,7 +16,7 @@ export function registerExport(program) {
15
16
  const engine = createEngine();
16
17
  const listOpts = {};
17
18
  if (opts.namespace) listOpts.namespace = opts.namespace;
18
- if (opts.limit) listOpts.limit = parseInt(opts.limit, 10);
19
+ if (opts.limit) listOpts.limit = parsePositiveInt(opts.limit, "--limit");
19
20
  listOpts.includeEmbeddings = opts.embeddings !== false ? false : false;
20
21
  // --no-embeddings sets opts.embeddings = false (commander negatable)
21
22
  // default: exclude embeddings for smaller output
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "fs";
1
+ import { readFileSync, existsSync } from "fs";
2
2
  import { createEngine } from "../core/engine.js";
3
3
  import { success, error, info, warn } from "../utils/style.js";
4
4
 
@@ -11,6 +11,11 @@ export function registerImport(program) {
11
11
  .option("--dry-run", "Parse and validate without storing")
12
12
  .action(async (file, opts) => {
13
13
  try {
14
+ if (!existsSync(file)) {
15
+ error(`File not found: ${file}`);
16
+ process.exitCode = 1;
17
+ return;
18
+ }
14
19
  const raw = readFileSync(file, "utf8");
15
20
  const lines = raw
16
21
  .split("\n")
@@ -2,6 +2,7 @@ import chalk from "chalk";
2
2
  import Table from "cli-table3";
3
3
  import { createEngine } from "../core/engine.js";
4
4
  import { error, heading } from "../utils/style.js";
5
+ import { parsePositiveInt, parseScore } from "../utils/validate.js";
5
6
 
6
7
  export function registerSearch(program) {
7
8
  program
@@ -23,13 +24,13 @@ export function registerSearch(program) {
23
24
  const engine = createEngine();
24
25
 
25
26
  const query = { text: queryText };
26
- query.limit = parseInt(opts.limit, 10);
27
+ query.limit = parsePositiveInt(opts.limit, "--limit");
27
28
  if (opts.namespace) query.namespace = opts.namespace;
28
- if (opts.timeStart) query.timeStart = parseInt(opts.timeStart, 10);
29
- if (opts.timeEnd) query.timeEnd = parseInt(opts.timeEnd, 10);
30
- if (opts.cluster) query.clusterId = parseInt(opts.cluster, 10);
31
- if (opts.followLinks) query.maxHops = parseInt(opts.followLinks, 10);
32
- if (opts.minScore) query.minScore = parseFloat(opts.minScore);
29
+ if (opts.timeStart) query.timeStart = parsePositiveInt(opts.timeStart, "--time-start");
30
+ if (opts.timeEnd) query.timeEnd = parsePositiveInt(opts.timeEnd, "--time-end");
31
+ if (opts.cluster) query.clusterId = parsePositiveInt(opts.cluster, "--cluster");
32
+ if (opts.followLinks) query.maxHops = parsePositiveInt(opts.followLinks, "--follow-links");
33
+ if (opts.minScore) query.minScore = parseScore(opts.minScore, "--min-score");
33
34
  if (opts.explain) query.withExplanation = true;
34
35
  if (opts.excludeSuperseded) query.excludeSuperseded = true;
35
36
 
@@ -1,5 +1,6 @@
1
1
  import { createEngine, loadConfig, saveConfig } from "../core/engine.js";
2
2
  import { success, error, info, warn } from "../utils/style.js";
3
+ import { parsePositiveInt } from "../utils/validate.js";
3
4
 
4
5
  export function registerStore(program) {
5
6
  program
@@ -17,7 +18,7 @@ export function registerStore(program) {
17
18
  if (opts.namespace) options.namespace = opts.namespace;
18
19
  if (opts.session) options.sessionId = opts.session;
19
20
  if (opts.tags) options.tags = opts.tags.split(",").map((t) => t.trim());
20
- if (opts.ttl) options.ttlSeconds = parseInt(opts.ttl, 10);
21
+ if (opts.ttl) options.ttlSeconds = parsePositiveInt(opts.ttl, "--ttl");
21
22
 
22
23
  // Model versioning check
23
24
  const currentModelId = engine.modelId();
@@ -0,0 +1,49 @@
1
+ import { logger } from "../utils/logger.js";
2
+
3
+ const DEFAULT_MAX_RETRIES = 3;
4
+ const DEFAULT_BACKOFF_MS = [500, 1000, 2000];
5
+
6
+ /**
7
+ * Create an engine with retry and exponential backoff.
8
+ * Intended for long-running servers (MCP, A2A) — not for one-shot CLI commands.
9
+ *
10
+ * @param {Function} factory - Function that creates the engine (e.g., createEngine)
11
+ * @param {object} [options]
12
+ * @param {number} [options.maxRetries=3]
13
+ * @param {number[]} [options.backoffMs=[500,1000,2000]]
14
+ * @returns {Promise<object>} The created engine
15
+ */
16
+ export async function createEngineWithRetry(factory, options = {}) {
17
+ const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
18
+ const backoffs = options.backoffMs ?? DEFAULT_BACKOFF_MS;
19
+
20
+ let lastError;
21
+
22
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
23
+ try {
24
+ const engine = factory();
25
+ if (attempt > 0) {
26
+ logger.info(`Engine connected after ${attempt + 1} attempts`);
27
+ }
28
+ return engine;
29
+ } catch (err) {
30
+ lastError = err;
31
+ if (attempt < maxRetries) {
32
+ const delay = backoffs[Math.min(attempt, backoffs.length - 1)];
33
+ logger.warn(
34
+ `Engine connection failed (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${delay}ms`,
35
+ {
36
+ error: err.message,
37
+ },
38
+ );
39
+ await new Promise((resolve) => setTimeout(resolve, delay));
40
+ }
41
+ }
42
+ }
43
+
44
+ logger.error(`Engine connection failed after ${maxRetries + 1} attempts`, {
45
+ error: lastError.message,
46
+ hint: "Is Qdrant running? Try: shabti config setup --check",
47
+ });
48
+ throw lastError;
49
+ }
package/src/index.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import { readFileSync } from "fs";
4
4
  import chalk from "chalk";
5
5
  import { Command } from "commander";
6
+ import { registerA2A } from "./commands/a2a.js";
6
7
  import { registerChat } from "./commands/chat.js";
7
8
  import { registerConfig } from "./commands/config.js";
8
9
  import { registerHello } from "./commands/hello.js";
@@ -71,6 +72,7 @@ function buildProgram() {
71
72
  .name("shabti")
72
73
  .description("Agent Memory OS — semantic memory for AI agents")
73
74
  .version(version);
75
+ registerA2A(program);
74
76
  registerChat(program);
75
77
  registerConfig(program);
76
78
  registerHello(program);
package/src/mcp/server.js CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { createInterface } from "readline";
3
3
  import { createEngine, loadConfig } from "../core/engine.js";
4
+ import { createEngineWithRetry } from "../core/retry.js";
5
+ import { logger } from "../utils/logger.js";
4
6
 
5
7
  const SERVER_INFO = {
6
8
  name: "shabti-memory",
@@ -123,11 +125,22 @@ let engineInitAttempted = false;
123
125
  function initEngine() {
124
126
  if (engine) return engine;
125
127
  if (engineInitAttempted) return null;
128
+ // Synchronous fallback for initial attempt
126
129
  engineInitAttempted = true;
127
130
  try {
128
131
  engine = createEngine();
129
- } catch {
130
- // engine stays null — tools will return errors
132
+ logger.info("Engine initialized");
133
+ } catch (err) {
134
+ logger.warn("Engine not available, starting background retry", { error: err.message });
135
+ // Start async retry in background
136
+ createEngineWithRetry(createEngine)
137
+ .then((eng) => {
138
+ engine = eng;
139
+ logger.info("Engine connected via retry");
140
+ })
141
+ .catch(() => {
142
+ logger.error("Engine retry exhausted — tools will return errors");
143
+ });
131
144
  }
132
145
  return engine;
133
146
  }
@@ -445,4 +458,20 @@ rl.on("line", (line) => {
445
458
  const trimmed = line.trim();
446
459
  if (trimmed) handleRequest(trimmed);
447
460
  });
448
- rl.on("close", () => process.exit(0));
461
+
462
+ async function shutdown() {
463
+ logger.info("MCP server shutting down");
464
+ rl.close();
465
+ if (engine && engine.shutdown) {
466
+ try {
467
+ await engine.shutdown();
468
+ } catch {
469
+ // best-effort
470
+ }
471
+ }
472
+ process.exit(0);
473
+ }
474
+
475
+ rl.on("close", shutdown);
476
+ process.on("SIGINT", shutdown);
477
+ process.on("SIGTERM", shutdown);
@@ -0,0 +1,40 @@
1
+ const LEVELS = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
2
+
3
+ function getLevel() {
4
+ const name = (process.env.SHABTI_LOG_LEVEL || "info").toLowerCase();
5
+ return LEVELS[name] ?? LEVELS.info;
6
+ }
7
+
8
+ function isJsonMode() {
9
+ return process.env.SHABTI_LOG_JSON === "1";
10
+ }
11
+
12
+ function shouldLog(level) {
13
+ return LEVELS[level] >= getLevel();
14
+ }
15
+
16
+ function formatTimestamp() {
17
+ return new Date().toISOString();
18
+ }
19
+
20
+ function log(level, message, data) {
21
+ if (!shouldLog(level)) return;
22
+
23
+ if (isJsonMode()) {
24
+ const entry = { timestamp: formatTimestamp(), level, message };
25
+ if (data !== undefined) entry.data = data;
26
+ process.stderr.write(JSON.stringify(entry) + "\n");
27
+ } else {
28
+ const prefix = `[${formatTimestamp()}] [${level.toUpperCase()}]`;
29
+ const msg =
30
+ data !== undefined ? `${prefix} ${message} ${JSON.stringify(data)}` : `${prefix} ${message}`;
31
+ process.stderr.write(msg + "\n");
32
+ }
33
+ }
34
+
35
+ export const logger = {
36
+ debug: (message, data) => log("debug", message, data),
37
+ info: (message, data) => log("info", message, data),
38
+ warn: (message, data) => log("warn", message, data),
39
+ error: (message, data) => log("error", message, data),
40
+ };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Parse a string as a positive integer. Returns the number or throws.
3
+ */
4
+ export function parsePositiveInt(value, name) {
5
+ const n = parseInt(value, 10);
6
+ if (isNaN(n) || n <= 0) {
7
+ throw new Error(`${name} must be a positive integer, got: ${value}`);
8
+ }
9
+ return n;
10
+ }
11
+
12
+ /**
13
+ * Parse a string as a non-negative integer (>= 0). Returns the number or throws.
14
+ */
15
+ export function parseNonNegativeInt(value, name) {
16
+ const n = parseInt(value, 10);
17
+ if (isNaN(n) || n < 0) {
18
+ throw new Error(`${name} must be a non-negative integer, got: ${value}`);
19
+ }
20
+ return n;
21
+ }
22
+
23
+ /**
24
+ * Parse a string as a positive float. Returns the number or throws.
25
+ */
26
+ export function parsePositiveFloat(value, name) {
27
+ const n = parseFloat(value);
28
+ if (isNaN(n) || n <= 0) {
29
+ throw new Error(`${name} must be a positive number, got: ${value}`);
30
+ }
31
+ return n;
32
+ }
33
+
34
+ /**
35
+ * Parse a string as a float in [0, 1]. Returns the number or throws.
36
+ */
37
+ export function parseScore(value, name) {
38
+ const n = parseFloat(value);
39
+ if (isNaN(n) || n < 0 || n > 1) {
40
+ throw new Error(`${name} must be a number between 0 and 1, got: ${value}`);
41
+ }
42
+ return n;
43
+ }
44
+
45
+ /**
46
+ * Parse a string as a valid TCP port (1–65535). Returns the number or throws.
47
+ */
48
+ export function parsePort(value, name) {
49
+ const n = parseInt(value, 10);
50
+ if (isNaN(n) || n < 1 || n > 65535) {
51
+ throw new Error(`${name} must be a port number (1–65535), got: ${value}`);
52
+ }
53
+ return n;
54
+ }