shabti 2.3.0 → 2.5.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.3.0",
3
+ "version": "2.5.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,371 @@
1
+ import { createServer } from "http";
2
+ import { randomUUID } from "crypto";
3
+ import { buildAgentCard } from "./agentCard.js";
4
+ import { createEngine } from "../core/engine.js";
5
+
6
+ // ============================================================
7
+ // Task Store (in-memory)
8
+ // ============================================================
9
+
10
+ export class TaskStore {
11
+ constructor() {
12
+ this.tasks = new Map();
13
+ }
14
+
15
+ create(contextId) {
16
+ const task = {
17
+ kind: "task",
18
+ id: randomUUID(),
19
+ contextId: contextId || randomUUID(),
20
+ status: { state: "submitted", timestamp: new Date().toISOString() },
21
+ artifacts: [],
22
+ history: [],
23
+ };
24
+ this.tasks.set(task.id, task);
25
+ return task;
26
+ }
27
+
28
+ get(id) {
29
+ return this.tasks.get(id) || null;
30
+ }
31
+
32
+ set(id, task) {
33
+ this.tasks.set(id, task);
34
+ }
35
+
36
+ clear() {
37
+ this.tasks.clear();
38
+ }
39
+ }
40
+
41
+ // ============================================================
42
+ // Skill Handlers
43
+ // ============================================================
44
+
45
+ async function handleMemoryStore(engine, parts) {
46
+ let content = null;
47
+ let opts = {};
48
+
49
+ for (const part of parts) {
50
+ if (part.kind === "data" && part.data) {
51
+ content = part.data.content || content;
52
+ if (part.data.namespace) opts.namespace = part.data.namespace;
53
+ if (part.data.tags) opts.tags = part.data.tags;
54
+ if (part.data.ttl) opts.ttlSeconds = part.data.ttl;
55
+ } else if (part.kind === "text" && part.text) {
56
+ content = content || part.text;
57
+ }
58
+ }
59
+
60
+ if (!content) {
61
+ throw Object.assign(new Error("Missing content in message parts"), { code: -32602 });
62
+ }
63
+
64
+ const result = await engine.store(content, opts);
65
+ return {
66
+ artifactId: randomUUID(),
67
+ name: "store_result",
68
+ parts: [{ kind: "data", data: { status: result.status, id: result.id || result.existingId } }],
69
+ };
70
+ }
71
+
72
+ async function handleMemorySearch(engine, parts) {
73
+ let query = null;
74
+ let limit = 10;
75
+
76
+ for (const part of parts) {
77
+ if (part.kind === "data" && part.data) {
78
+ query = part.data.query || query;
79
+ if (part.data.limit) limit = part.data.limit;
80
+ } else if (part.kind === "text" && part.text) {
81
+ query = query || part.text;
82
+ }
83
+ }
84
+
85
+ if (!query) {
86
+ throw Object.assign(new Error("Missing query in message parts"), { code: -32602 });
87
+ }
88
+
89
+ const results = await engine.executeQuery({ text: query, limit });
90
+ const formatted = results.map((r) => ({
91
+ id: r.id,
92
+ content: r.content,
93
+ score: r.score,
94
+ namespace: r.namespace,
95
+ }));
96
+
97
+ return {
98
+ artifactId: randomUUID(),
99
+ name: "search_results",
100
+ parts: [{ kind: "data", data: { query, results: formatted, count: formatted.length } }],
101
+ };
102
+ }
103
+
104
+ function handleMemoryStatus(engine) {
105
+ const status = engine.status();
106
+ return {
107
+ artifactId: randomUUID(),
108
+ name: "status",
109
+ parts: [
110
+ {
111
+ kind: "data",
112
+ data: {
113
+ status: "ok",
114
+ entry_count: status.entryCount,
115
+ tier: status.tier,
116
+ model_id: status.modelId,
117
+ },
118
+ },
119
+ ],
120
+ };
121
+ }
122
+
123
+ // ============================================================
124
+ // Skill Router
125
+ // ============================================================
126
+
127
+ export function resolveSkill(parts) {
128
+ // Check for explicit skill in data parts
129
+ for (const part of parts) {
130
+ if (part.kind === "data" && part.data?.skill) {
131
+ return part.data.skill;
132
+ }
133
+ }
134
+
135
+ // Text-based routing
136
+ for (const part of parts) {
137
+ if (part.kind === "text" && part.text) {
138
+ const lower = part.text.toLowerCase();
139
+ if (/\b(store|save|remember)\b/.test(lower)) return "memory_store";
140
+ if (/\b(search|find|recall|query)\b/.test(lower)) return "memory_search";
141
+ if (/\b(status|health|stats|info)\b/.test(lower)) return "memory_status";
142
+ }
143
+ }
144
+
145
+ // Check for structured data that implies a skill
146
+ for (const part of parts) {
147
+ if (part.kind === "data" && part.data) {
148
+ if (part.data.content) return "memory_store";
149
+ if (part.data.query) return "memory_search";
150
+ }
151
+ }
152
+
153
+ return null;
154
+ }
155
+
156
+ // ============================================================
157
+ // JSON-RPC Dispatcher (testable without HTTP)
158
+ // ============================================================
159
+
160
+ export async function dispatchRpc(engine, taskStore, method, params) {
161
+ switch (method) {
162
+ case "message/send":
163
+ return handleMessageSend(engine, taskStore, params || {});
164
+ case "tasks/get":
165
+ return handleTasksGet(taskStore, params || {});
166
+ case "tasks/cancel":
167
+ return handleTasksCancel(taskStore, params || {});
168
+ default:
169
+ return { error: { code: -32601, message: `Method not found: ${method}` } };
170
+ }
171
+ }
172
+
173
+ async function handleMessageSend(engine, taskStore, params) {
174
+ const message = params?.message;
175
+ if (!message || !message.parts || message.parts.length === 0) {
176
+ return { error: { code: -32602, message: "Missing message or parts" } };
177
+ }
178
+
179
+ const skill = resolveSkill(message.parts);
180
+ if (!skill) {
181
+ return {
182
+ error: { code: -32005, message: "Could not determine which skill to invoke from message" },
183
+ };
184
+ }
185
+
186
+ const task = taskStore.create(message.contextId);
187
+
188
+ // Store the incoming message in history
189
+ task.history.push(message);
190
+
191
+ try {
192
+ task.status = { state: "working", timestamp: new Date().toISOString() };
193
+
194
+ let artifact;
195
+ switch (skill) {
196
+ case "memory_store":
197
+ artifact = await handleMemoryStore(engine, message.parts);
198
+ break;
199
+ case "memory_search":
200
+ artifact = await handleMemorySearch(engine, message.parts);
201
+ break;
202
+ case "memory_status":
203
+ artifact = handleMemoryStatus(engine);
204
+ break;
205
+ default:
206
+ task.status = { state: "failed", timestamp: new Date().toISOString() };
207
+ taskStore.set(task.id, task);
208
+ return { error: { code: -32004, message: `Unknown skill: ${skill}` } };
209
+ }
210
+
211
+ task.artifacts = [artifact];
212
+ task.status = { state: "completed", timestamp: new Date().toISOString() };
213
+ } catch (err) {
214
+ task.status = {
215
+ state: "failed",
216
+ timestamp: new Date().toISOString(),
217
+ message: {
218
+ kind: "message",
219
+ role: "agent",
220
+ messageId: randomUUID(),
221
+ parts: [{ kind: "text", text: err.message }],
222
+ },
223
+ };
224
+ taskStore.set(task.id, task);
225
+ if (err.code) return { error: { code: err.code, message: err.message } };
226
+ return { error: { code: -32603, message: err.message } };
227
+ }
228
+
229
+ taskStore.set(task.id, task);
230
+ return { result: task };
231
+ }
232
+
233
+ function handleTasksGet(taskStore, params) {
234
+ const id = params?.id;
235
+ if (!id) return { error: { code: -32602, message: "Missing task id" } };
236
+
237
+ const task = taskStore.get(id);
238
+ if (!task) return { error: { code: -32001, message: `Task not found: ${id}` } };
239
+
240
+ // Apply historyLength filter
241
+ if (params.historyLength !== undefined) {
242
+ const result = { ...task };
243
+ const n = params.historyLength;
244
+ result.history = n === 0 ? [] : task.history.slice(-n);
245
+ return { result };
246
+ }
247
+
248
+ return { result: task };
249
+ }
250
+
251
+ function handleTasksCancel(taskStore, params) {
252
+ const id = params?.id;
253
+ if (!id) return { error: { code: -32602, message: "Missing task id" } };
254
+
255
+ const task = taskStore.get(id);
256
+ if (!task) return { error: { code: -32001, message: `Task not found: ${id}` } };
257
+
258
+ const terminal = ["completed", "failed", "canceled", "rejected"];
259
+ if (terminal.includes(task.status.state)) {
260
+ return {
261
+ error: { code: -32002, message: `Task is already in terminal state: ${task.status.state}` },
262
+ };
263
+ }
264
+
265
+ task.status = { state: "canceled", timestamp: new Date().toISOString() };
266
+ taskStore.set(task.id, task);
267
+ return { result: task };
268
+ }
269
+
270
+ // ============================================================
271
+ // HTTP Server
272
+ // ============================================================
273
+
274
+ function readBody(req) {
275
+ return new Promise((resolve, reject) => {
276
+ const chunks = [];
277
+ req.on("data", (c) => chunks.push(c));
278
+ req.on("end", () => resolve(Buffer.concat(chunks).toString()));
279
+ req.on("error", reject);
280
+ });
281
+ }
282
+
283
+ export function startA2AServer(port = 3000) {
284
+ let engine = null;
285
+ let engineError = null;
286
+
287
+ function getEngine() {
288
+ if (engine) return engine;
289
+ if (engineError) throw engineError;
290
+ try {
291
+ engine = createEngine();
292
+ } catch (err) {
293
+ engineError = err;
294
+ throw err;
295
+ }
296
+ return engine;
297
+ }
298
+
299
+ const baseUrl = `http://localhost:${port}/`;
300
+ const agentCard = buildAgentCard(baseUrl);
301
+ const taskStore = new TaskStore();
302
+
303
+ const server = createServer(async (req, res) => {
304
+ // CORS headers
305
+ res.setHeader("Access-Control-Allow-Origin", "*");
306
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
307
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
308
+
309
+ if (req.method === "OPTIONS") {
310
+ res.writeHead(204);
311
+ return res.end();
312
+ }
313
+
314
+ // Agent Card discovery
315
+ if (req.method === "GET" && req.url === "/.well-known/agent-card.json") {
316
+ res.writeHead(200, { "Content-Type": "application/json" });
317
+ return res.end(JSON.stringify(agentCard, null, 2));
318
+ }
319
+
320
+ // JSON-RPC endpoint
321
+ if (req.method === "POST" && (req.url === "/" || req.url === "")) {
322
+ let body;
323
+ try {
324
+ body = await readBody(req);
325
+ } catch {
326
+ res.writeHead(400);
327
+ return res.end();
328
+ }
329
+
330
+ let rpc;
331
+ try {
332
+ rpc = JSON.parse(body);
333
+ } catch {
334
+ res.writeHead(200, { "Content-Type": "application/json" });
335
+ return res.end(
336
+ JSON.stringify({
337
+ jsonrpc: "2.0",
338
+ id: null,
339
+ error: { code: -32700, message: "Parse error" },
340
+ }),
341
+ );
342
+ }
343
+
344
+ const { id, method, params } = rpc;
345
+ let response;
346
+
347
+ try {
348
+ response = await dispatchRpc(getEngine(), taskStore, method, params);
349
+ } catch (err) {
350
+ response = { error: { code: -32603, message: `Engine error: ${err.message}` } };
351
+ }
352
+
353
+ res.writeHead(200, { "Content-Type": "application/json" });
354
+ if (response.error) {
355
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, error: response.error }));
356
+ }
357
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, result: response.result }));
358
+ }
359
+
360
+ res.writeHead(404);
361
+ res.end("Not Found");
362
+ });
363
+
364
+ server.listen(port, "127.0.0.1", () => {
365
+ console.log(`\n Shabti A2A server listening on ${baseUrl}`);
366
+ console.log(` Agent Card: ${baseUrl}.well-known/agent-card.json`);
367
+ console.log(` Skills: ${agentCard.skills.map((s) => s.id).join(", ")}\n`);
368
+ });
369
+
370
+ return server;
371
+ }
@@ -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,12 @@
1
+ import { startA2AServer } from "../a2a/server.js";
2
+
3
+ export function registerA2A(program) {
4
+ program
5
+ .command("a2a")
6
+ .description("Start the A2A (Agent-to-Agent) protocol server")
7
+ .option("-p, --port <port>", "Port to listen on", "3000")
8
+ .action((opts) => {
9
+ const port = parseInt(opts.port, 10);
10
+ startA2AServer(port);
11
+ });
12
+ }
@@ -0,0 +1,44 @@
1
+ import { writeFileSync } from "fs";
2
+ import { createEngine } from "../core/engine.js";
3
+ import { success, error } from "../utils/style.js";
4
+
5
+ export function registerExport(program) {
6
+ program
7
+ .command("export")
8
+ .description("Export memory entries as JSONL")
9
+ .option("-n, --namespace <ns>", "Filter by namespace")
10
+ .option("--no-embeddings", "Exclude embedding vectors from output")
11
+ .option("-o, --output <file>", "Write to file instead of stdout")
12
+ .option("--limit <n>", "Maximum number of entries to export")
13
+ .action(async (opts) => {
14
+ try {
15
+ const engine = createEngine();
16
+ const listOpts = {};
17
+ if (opts.namespace) listOpts.namespace = opts.namespace;
18
+ if (opts.limit) listOpts.limit = parseInt(opts.limit, 10);
19
+ listOpts.includeEmbeddings = opts.embeddings !== false ? false : false;
20
+ // --no-embeddings sets opts.embeddings = false (commander negatable)
21
+ // default: exclude embeddings for smaller output
22
+ listOpts.includeEmbeddings = opts.embeddings === true;
23
+
24
+ const entries = engine.listEntries(listOpts);
25
+
26
+ const lines = entries.map((e) => JSON.stringify(e));
27
+ const output = lines.join("\n");
28
+
29
+ if (opts.output) {
30
+ writeFileSync(opts.output, output + (lines.length ? "\n" : ""), "utf8");
31
+ success(`Exported ${entries.length} entries to ${opts.output}`);
32
+ } else {
33
+ if (output) console.log(output);
34
+ // Print summary to stderr so it doesn't pollute piped output
35
+ process.stderr.write(`\n${entries.length} entries exported\n`);
36
+ }
37
+
38
+ await engine.shutdown();
39
+ } catch (err) {
40
+ error(err.message);
41
+ process.exitCode = 1;
42
+ }
43
+ });
44
+ }
@@ -0,0 +1,66 @@
1
+ import { readFileSync } from "fs";
2
+ import { createEngine } from "../core/engine.js";
3
+ import { success, error, info, warn } from "../utils/style.js";
4
+
5
+ export function registerImport(program) {
6
+ program
7
+ .command("import")
8
+ .description("Import memory entries from a JSONL file")
9
+ .argument("<file>", "Path to JSONL file")
10
+ .option("-n, --namespace <ns>", "Override namespace for all imported entries")
11
+ .option("--dry-run", "Parse and validate without storing")
12
+ .action(async (file, opts) => {
13
+ try {
14
+ const raw = readFileSync(file, "utf8");
15
+ const lines = raw
16
+ .split("\n")
17
+ .map((l) => l.trim())
18
+ .filter((l) => l.length > 0);
19
+
20
+ if (lines.length === 0) {
21
+ info("No entries found in file.");
22
+ return;
23
+ }
24
+
25
+ // Parse all lines first to validate
26
+ const entries = [];
27
+ for (let i = 0; i < lines.length; i++) {
28
+ try {
29
+ entries.push(JSON.parse(lines[i]));
30
+ } catch {
31
+ warn(`Skipping invalid JSON on line ${i + 1}`);
32
+ }
33
+ }
34
+
35
+ if (opts.dryRun) {
36
+ info(`Dry run: ${entries.length} entries parsed, 0 stored.`);
37
+ return;
38
+ }
39
+
40
+ const engine = createEngine();
41
+ let stored = 0;
42
+ let skipped = 0;
43
+
44
+ for (const entry of entries) {
45
+ const storeOpts = {};
46
+ storeOpts.namespace = opts.namespace || entry.namespace || undefined;
47
+ if (entry.tags && entry.tags.length) storeOpts.tags = entry.tags;
48
+ if (entry.sessionId || entry.session_id)
49
+ storeOpts.sessionId = entry.sessionId || entry.session_id;
50
+
51
+ const result = await engine.store(entry.content, storeOpts);
52
+ if (result.status === "stored") {
53
+ stored++;
54
+ } else {
55
+ skipped++;
56
+ }
57
+ }
58
+
59
+ success(`Import complete: ${stored} stored, ${skipped} skipped (duplicates)`);
60
+ await engine.shutdown();
61
+ } catch (err) {
62
+ error(err.message);
63
+ process.exitCode = 1;
64
+ }
65
+ });
66
+ }
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";
@@ -11,6 +12,8 @@ import { registerSearch } from "./commands/search.js";
11
12
  import { registerSnapshot } from "./commands/snapshot.js";
12
13
  import { registerSpin } from "./commands/spin.js";
13
14
  import { registerStatus } from "./commands/status.js";
15
+ import { registerExport } from "./commands/export.js";
16
+ import { registerImport } from "./commands/import.js";
14
17
  import { registerStore } from "./commands/store.js";
15
18
 
16
19
  const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
@@ -69,6 +72,7 @@ function buildProgram() {
69
72
  .name("shabti")
70
73
  .description("Agent Memory OS — semantic memory for AI agents")
71
74
  .version(version);
75
+ registerA2A(program);
72
76
  registerChat(program);
73
77
  registerConfig(program);
74
78
  registerHello(program);
@@ -77,6 +81,8 @@ function buildProgram() {
77
81
  registerSnapshot(program);
78
82
  registerSpin(program);
79
83
  registerStatus(program);
84
+ registerExport(program);
85
+ registerImport(program);
80
86
  registerStore(program);
81
87
 
82
88
  program
package/src/mcp/server.js CHANGED
@@ -73,6 +73,17 @@ const TOOLS = [
73
73
  },
74
74
  },
75
75
  },
76
+ {
77
+ name: "memory_export",
78
+ description: "Export all memory entries as a JSONL array",
79
+ inputSchema: {
80
+ type: "object",
81
+ properties: {
82
+ namespace: { type: "string", description: "Filter by namespace" },
83
+ limit: { type: "integer", description: "Maximum entries to export" },
84
+ },
85
+ },
86
+ },
76
87
  {
77
88
  name: "memory_gc",
78
89
  description: "Garbage collect expired memory entries (removes entries past their TTL)",
@@ -306,6 +317,29 @@ async function handleToolsCall(id, params) {
306
317
  }
307
318
  }
308
319
 
320
+ if (name === "memory_export") {
321
+ if (!eng) {
322
+ return respondError(id, -32603, "Engine not available");
323
+ }
324
+ try {
325
+ const listOpts = {};
326
+ if (args?.namespace) listOpts.namespace = args.namespace;
327
+ if (args?.limit) listOpts.limit = args.limit;
328
+ const entries = eng.listEntries(listOpts);
329
+ const lines = entries.map((e) => JSON.stringify(e));
330
+ return respond(id, {
331
+ content: [
332
+ {
333
+ type: "text",
334
+ text: JSON.stringify({ entries: entries.length, data: lines.join("\n") }, null, 2),
335
+ },
336
+ ],
337
+ });
338
+ } catch (err) {
339
+ return respondError(id, -32603, err.message);
340
+ }
341
+ }
342
+
309
343
  if (name === "memory_gc") {
310
344
  if (!eng) {
311
345
  return respondError(id, -32603, "Engine not available");