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 +1 -1
- package/src/a2a/agentCard.js +48 -0
- package/src/a2a/server.js +390 -0
- package/src/a2a/standalone.js +9 -0
- package/src/commands/a2a.js +19 -0
- package/src/commands/export.js +2 -1
- package/src/commands/import.js +6 -1
- package/src/commands/search.js +7 -6
- package/src/commands/store.js +2 -1
- package/src/core/retry.js +49 -0
- package/src/index.js +2 -0
- package/src/mcp/server.js +32 -3
- package/src/utils/logger.js +40 -0
- package/src/utils/validate.js +54 -0
package/package.json
CHANGED
|
@@ -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,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
|
+
}
|
package/src/commands/export.js
CHANGED
|
@@ -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 =
|
|
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
|
package/src/commands/import.js
CHANGED
|
@@ -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")
|
package/src/commands/search.js
CHANGED
|
@@ -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 =
|
|
27
|
+
query.limit = parsePositiveInt(opts.limit, "--limit");
|
|
27
28
|
if (opts.namespace) query.namespace = opts.namespace;
|
|
28
|
-
if (opts.timeStart) query.timeStart =
|
|
29
|
-
if (opts.timeEnd) query.timeEnd =
|
|
30
|
-
if (opts.cluster) query.clusterId =
|
|
31
|
-
if (opts.followLinks) query.maxHops =
|
|
32
|
-
if (opts.minScore) query.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
|
|
package/src/commands/store.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
+
}
|