shabti 2.11.0 → 2.12.1
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/README.md +2 -0
- package/package.json +1 -1
- package/src/a2a/agentCard.js +48 -0
- package/src/a2a/server.js +193 -11
- package/src/commands/config.js +50 -2
- package/src/commands/export.js +4 -2
- package/src/commands/import.js +5 -3
- package/src/commands/snapshot.js +6 -0
- package/src/commands/status.js +0 -2
- package/src/core/engine.js +15 -6
- package/src/core/session.js +18 -0
- package/src/mcp/server.js +105 -4
- package/src/repl/slashCommands.js +8 -3
- package/src/utils/validate.js +17 -0
package/README.md
CHANGED
|
@@ -210,11 +210,13 @@ Or manually add to your MCP settings:
|
|
|
210
210
|
| --------------- | ----------------------------------------------------------- |
|
|
211
211
|
| `memory_store` | Store a memory entry with optional namespace, tags, and TTL |
|
|
212
212
|
| `memory_search` | Search memories by semantic similarity |
|
|
213
|
+
| `memory_get` | Retrieve a specific memory entry by UUID |
|
|
213
214
|
| `memory_delete` | Delete a memory entry by ID |
|
|
214
215
|
| `memory_list` | List recent memory entries |
|
|
215
216
|
| `memory_export` | Export entries as JSONL |
|
|
216
217
|
| `memory_gc` | Garbage collect expired entries |
|
|
217
218
|
| `memory_status` | Get engine status |
|
|
219
|
+
| `memory_health` | Run health checks on engine components |
|
|
218
220
|
|
|
219
221
|
### MCP Resources
|
|
220
222
|
|
package/package.json
CHANGED
package/src/a2a/agentCard.js
CHANGED
|
@@ -43,6 +43,54 @@ export function buildAgentCard(baseUrl) {
|
|
|
43
43
|
inputModes: ["text"],
|
|
44
44
|
outputModes: ["application/json"],
|
|
45
45
|
},
|
|
46
|
+
{
|
|
47
|
+
id: "memory_get",
|
|
48
|
+
name: "Get Memory",
|
|
49
|
+
description: "Retrieve a specific memory entry by its ID",
|
|
50
|
+
tags: ["memory", "get", "retrieve", "fetch"],
|
|
51
|
+
inputModes: ["application/json"],
|
|
52
|
+
outputModes: ["application/json"],
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: "memory_delete",
|
|
56
|
+
name: "Delete Memory",
|
|
57
|
+
description: "Delete a specific memory entry by its ID",
|
|
58
|
+
tags: ["memory", "delete", "remove"],
|
|
59
|
+
inputModes: ["application/json"],
|
|
60
|
+
outputModes: ["application/json"],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "memory_list",
|
|
64
|
+
name: "List Memories",
|
|
65
|
+
description: "List stored memory entries with optional filters",
|
|
66
|
+
tags: ["memory", "list", "entries"],
|
|
67
|
+
inputModes: ["text", "application/json"],
|
|
68
|
+
outputModes: ["application/json"],
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: "memory_export",
|
|
72
|
+
name: "Export Memories",
|
|
73
|
+
description: "Export all memory entries as JSONL data",
|
|
74
|
+
tags: ["memory", "export", "dump", "backup"],
|
|
75
|
+
inputModes: ["text", "application/json"],
|
|
76
|
+
outputModes: ["application/json"],
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: "memory_gc",
|
|
80
|
+
name: "Garbage Collect",
|
|
81
|
+
description: "Remove expired memory entries and reclaim storage",
|
|
82
|
+
tags: ["memory", "gc", "garbage", "cleanup"],
|
|
83
|
+
inputModes: ["text"],
|
|
84
|
+
outputModes: ["application/json"],
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: "memory_health",
|
|
88
|
+
name: "Health Check",
|
|
89
|
+
description: "Check the health of the memory engine and its dependencies",
|
|
90
|
+
tags: ["memory", "health", "diagnostics"],
|
|
91
|
+
inputModes: ["text"],
|
|
92
|
+
outputModes: ["application/json"],
|
|
93
|
+
},
|
|
46
94
|
],
|
|
47
95
|
};
|
|
48
96
|
}
|
package/src/a2a/server.js
CHANGED
|
@@ -10,8 +10,13 @@ import { normalizeText } from "../utils/normalize.js";
|
|
|
10
10
|
// ============================================================
|
|
11
11
|
|
|
12
12
|
export class TaskStore {
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
#tasks = new Map();
|
|
14
|
+
#maxTasks;
|
|
15
|
+
#ttlMs;
|
|
16
|
+
|
|
17
|
+
constructor({ maxTasks = 1000, ttlMs = 30 * 60 * 1000 } = {}) {
|
|
18
|
+
this.#maxTasks = maxTasks;
|
|
19
|
+
this.#ttlMs = ttlMs;
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
create(contextId) {
|
|
@@ -22,21 +27,44 @@ export class TaskStore {
|
|
|
22
27
|
status: { state: "submitted", timestamp: new Date().toISOString() },
|
|
23
28
|
artifacts: [],
|
|
24
29
|
history: [],
|
|
30
|
+
createdAt: Date.now(),
|
|
25
31
|
};
|
|
26
|
-
this
|
|
32
|
+
this.#tasks.set(task.id, task);
|
|
33
|
+
|
|
34
|
+
if (this.#tasks.size > this.#maxTasks) {
|
|
35
|
+
this.#evictOldest();
|
|
36
|
+
}
|
|
37
|
+
|
|
27
38
|
return task;
|
|
28
39
|
}
|
|
29
40
|
|
|
41
|
+
#evictOldest() {
|
|
42
|
+
const oldest = [...this.#tasks.entries()].sort(([, a], [, b]) => a.createdAt - b.createdAt)[0];
|
|
43
|
+
if (oldest) this.#tasks.delete(oldest[0]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
cleanup() {
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
for (const [id, task] of this.#tasks) {
|
|
49
|
+
if (
|
|
50
|
+
now - task.createdAt > this.#ttlMs &&
|
|
51
|
+
["completed", "canceled", "failed"].includes(task.status.state)
|
|
52
|
+
) {
|
|
53
|
+
this.#tasks.delete(id);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
30
58
|
get(id) {
|
|
31
|
-
return this
|
|
59
|
+
return this.#tasks.get(id) || null;
|
|
32
60
|
}
|
|
33
61
|
|
|
34
62
|
set(id, task) {
|
|
35
|
-
this
|
|
63
|
+
this.#tasks.set(id, task);
|
|
36
64
|
}
|
|
37
65
|
|
|
38
66
|
clear() {
|
|
39
|
-
this
|
|
67
|
+
this.#tasks.clear();
|
|
40
68
|
}
|
|
41
69
|
}
|
|
42
70
|
|
|
@@ -122,6 +150,112 @@ function handleMemoryStatus(engine) {
|
|
|
122
150
|
};
|
|
123
151
|
}
|
|
124
152
|
|
|
153
|
+
async function handleMemoryDelete(engine, parts) {
|
|
154
|
+
let id = null;
|
|
155
|
+
|
|
156
|
+
for (const part of parts) {
|
|
157
|
+
if (part.kind === "data" && part.data?.id) {
|
|
158
|
+
id = part.data.id;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!id) {
|
|
163
|
+
throw Object.assign(new Error("Missing id in message parts"), { code: -32602 });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
await engine.delete(id);
|
|
167
|
+
return {
|
|
168
|
+
artifactId: randomUUID(),
|
|
169
|
+
name: "delete_result",
|
|
170
|
+
parts: [{ kind: "data", data: { deleted: true, id } }],
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function handleMemoryGet(engine, parts) {
|
|
175
|
+
let id = null;
|
|
176
|
+
|
|
177
|
+
for (const part of parts) {
|
|
178
|
+
if (part.kind === "data" && part.data?.id) {
|
|
179
|
+
id = part.data.id;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!id) {
|
|
184
|
+
throw Object.assign(new Error("Missing id in message parts"), { code: -32602 });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const entry = await engine.get(id);
|
|
188
|
+
return {
|
|
189
|
+
artifactId: randomUUID(),
|
|
190
|
+
name: "get_result",
|
|
191
|
+
parts: [{ kind: "data", data: entry }],
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function handleMemoryList(engine, parts) {
|
|
196
|
+
let opts = {};
|
|
197
|
+
|
|
198
|
+
for (const part of parts) {
|
|
199
|
+
if (part.kind === "data" && part.data) {
|
|
200
|
+
if (part.data.limit) opts.limit = part.data.limit;
|
|
201
|
+
if (part.data.namespace) opts.namespace = part.data.namespace;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const entries = engine.listEntries(opts);
|
|
206
|
+
return {
|
|
207
|
+
artifactId: randomUUID(),
|
|
208
|
+
name: "list_result",
|
|
209
|
+
parts: [{ kind: "data", data: { entries, count: entries.length } }],
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function handleMemoryExport(engine) {
|
|
214
|
+
const entries = engine.listEntries();
|
|
215
|
+
const lines = entries.map((e) => JSON.stringify(e));
|
|
216
|
+
return {
|
|
217
|
+
artifactId: randomUUID(),
|
|
218
|
+
name: "export_result",
|
|
219
|
+
parts: [{ kind: "data", data: { entries: entries.length, data: lines.join("\n") } }],
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function handleMemoryGc(engine) {
|
|
224
|
+
const removed = await engine.gc();
|
|
225
|
+
return {
|
|
226
|
+
artifactId: randomUUID(),
|
|
227
|
+
name: "gc_result",
|
|
228
|
+
parts: [{ kind: "data", data: { removed } }],
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function handleMemoryHealth(engine) {
|
|
233
|
+
const checks = [];
|
|
234
|
+
const status = engine.status();
|
|
235
|
+
const qdrantUrl = status.qdrantUrl.replace(/:\d+$/, ":6333");
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
const res = await fetch(`${qdrantUrl}/healthz`, { signal: AbortSignal.timeout(3000) });
|
|
239
|
+
checks.push({
|
|
240
|
+
name: "qdrant",
|
|
241
|
+
status: res.ok ? "ok" : "degraded",
|
|
242
|
+
message: res.ok ? "Reachable" : `HTTP ${res.status}`,
|
|
243
|
+
});
|
|
244
|
+
} catch (err) {
|
|
245
|
+
checks.push({ name: "qdrant", status: "error", message: err.message });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
checks.push({ name: "engine", status: "ok", message: `${status.entryCount} entries` });
|
|
249
|
+
checks.push({ name: "embedding", status: "ok", message: `Model: ${engine.modelId()}` });
|
|
250
|
+
|
|
251
|
+
const healthy = checks.every((c) => c.status === "ok");
|
|
252
|
+
return {
|
|
253
|
+
artifactId: randomUUID(),
|
|
254
|
+
name: "health_result",
|
|
255
|
+
parts: [{ kind: "data", data: { healthy, checks } }],
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
125
259
|
// ============================================================
|
|
126
260
|
// Skill Router
|
|
127
261
|
// ============================================================
|
|
@@ -140,6 +274,11 @@ export function resolveSkill(parts) {
|
|
|
140
274
|
const lower = part.text.toLowerCase();
|
|
141
275
|
if (/\b(store|save|remember)\b/.test(lower)) return "memory_store";
|
|
142
276
|
if (/\b(search|find|recall|query)\b/.test(lower)) return "memory_search";
|
|
277
|
+
if (/\b(delete|remove)\b/.test(lower)) return "memory_delete";
|
|
278
|
+
if (/\b(get|retrieve)\b/.test(lower)) return "memory_get";
|
|
279
|
+
if (/\b(list)\b/.test(lower)) return "memory_list";
|
|
280
|
+
if (/\b(export|dump)\b/.test(lower)) return "memory_export";
|
|
281
|
+
if (/\b(garbage|cleanup)\b/.test(lower)) return "memory_gc";
|
|
143
282
|
if (/\b(status|health|stats|info)\b/.test(lower)) return "memory_status";
|
|
144
283
|
}
|
|
145
284
|
}
|
|
@@ -149,6 +288,7 @@ export function resolveSkill(parts) {
|
|
|
149
288
|
if (part.kind === "data" && part.data) {
|
|
150
289
|
if (part.data.content) return "memory_store";
|
|
151
290
|
if (part.data.query) return "memory_search";
|
|
291
|
+
if (part.data.id) return "memory_get";
|
|
152
292
|
}
|
|
153
293
|
}
|
|
154
294
|
|
|
@@ -204,6 +344,24 @@ async function handleMessageSend(engine, taskStore, params) {
|
|
|
204
344
|
case "memory_status":
|
|
205
345
|
artifact = handleMemoryStatus(engine);
|
|
206
346
|
break;
|
|
347
|
+
case "memory_delete":
|
|
348
|
+
artifact = await handleMemoryDelete(engine, message.parts);
|
|
349
|
+
break;
|
|
350
|
+
case "memory_get":
|
|
351
|
+
artifact = await handleMemoryGet(engine, message.parts);
|
|
352
|
+
break;
|
|
353
|
+
case "memory_list":
|
|
354
|
+
artifact = handleMemoryList(engine, message.parts);
|
|
355
|
+
break;
|
|
356
|
+
case "memory_export":
|
|
357
|
+
artifact = handleMemoryExport(engine);
|
|
358
|
+
break;
|
|
359
|
+
case "memory_gc":
|
|
360
|
+
artifact = await handleMemoryGc(engine);
|
|
361
|
+
break;
|
|
362
|
+
case "memory_health":
|
|
363
|
+
artifact = await handleMemoryHealth(engine);
|
|
364
|
+
break;
|
|
207
365
|
default:
|
|
208
366
|
task.status = { state: "failed", timestamp: new Date().toISOString() };
|
|
209
367
|
taskStore.set(task.id, task);
|
|
@@ -273,10 +431,19 @@ function handleTasksCancel(taskStore, params) {
|
|
|
273
431
|
// HTTP Server
|
|
274
432
|
// ============================================================
|
|
275
433
|
|
|
276
|
-
function readBody(req) {
|
|
434
|
+
function readBody(req, maxBytes = 1_048_576) {
|
|
435
|
+
// デフォルト1MB上限
|
|
277
436
|
return new Promise((resolve, reject) => {
|
|
278
437
|
const chunks = [];
|
|
279
|
-
|
|
438
|
+
let total = 0;
|
|
439
|
+
req.on("data", (c) => {
|
|
440
|
+
total += c.length;
|
|
441
|
+
if (total > maxBytes) {
|
|
442
|
+
req.destroy();
|
|
443
|
+
return reject(new Error("リクエストボディが大きすぎます"));
|
|
444
|
+
}
|
|
445
|
+
chunks.push(c);
|
|
446
|
+
});
|
|
280
447
|
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
281
448
|
req.on("error", reject);
|
|
282
449
|
});
|
|
@@ -304,17 +471,30 @@ export function startA2AServer(port = 3000) {
|
|
|
304
471
|
const agentCard = buildAgentCard(baseUrl);
|
|
305
472
|
const taskStore = new TaskStore();
|
|
306
473
|
|
|
474
|
+
const cleanupInterval = setInterval(() => taskStore.cleanup(), 5 * 60 * 1000);
|
|
475
|
+
|
|
307
476
|
const server = createServer(async (req, res) => {
|
|
308
|
-
// CORS headers
|
|
309
|
-
res.setHeader("Access-Control-Allow-Origin", "
|
|
477
|
+
// CORS headers — restrict to null (no browser cross-origin access needed for localhost)
|
|
478
|
+
res.setHeader("Access-Control-Allow-Origin", "null");
|
|
310
479
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
311
|
-
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
480
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
312
481
|
|
|
313
482
|
if (req.method === "OPTIONS") {
|
|
314
483
|
res.writeHead(204);
|
|
315
484
|
return res.end();
|
|
316
485
|
}
|
|
317
486
|
|
|
487
|
+
// Bearer Token authentication
|
|
488
|
+
const a2aToken = process.env.SHABTI_A2A_TOKEN;
|
|
489
|
+
if (a2aToken) {
|
|
490
|
+
const authHeader = req.headers["authorization"] || "";
|
|
491
|
+
if (!authHeader.startsWith("Bearer ") || authHeader.slice(7) !== a2aToken) {
|
|
492
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
493
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
318
498
|
// Agent Card discovery
|
|
319
499
|
if (req.method === "GET" && req.url === "/.well-known/agent-card.json") {
|
|
320
500
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
@@ -365,6 +545,8 @@ export function startA2AServer(port = 3000) {
|
|
|
365
545
|
res.end("Not Found");
|
|
366
546
|
});
|
|
367
547
|
|
|
548
|
+
server.on("close", () => clearInterval(cleanupInterval));
|
|
549
|
+
|
|
368
550
|
server.listen(port, "127.0.0.1", () => {
|
|
369
551
|
console.log(`\n Shabti A2A server listening on ${baseUrl}`);
|
|
370
552
|
console.log(` Agent Card: ${baseUrl}.well-known/agent-card.json`);
|
package/src/commands/config.js
CHANGED
|
@@ -1,9 +1,38 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { loadConfig, saveConfig, DEFAULT_CONFIG } from "../core/engine.js";
|
|
3
3
|
import { success, error, heading } from "../utils/style.js";
|
|
4
|
+
import { validateQdrantUrl } from "../utils/validate.js";
|
|
4
5
|
|
|
5
6
|
const ALLOWED_KEYS = Object.keys(DEFAULT_CONFIG);
|
|
6
7
|
|
|
8
|
+
// キーごとのバリデーター
|
|
9
|
+
const CONFIG_VALIDATORS = {
|
|
10
|
+
qdrant_url: (value) => {
|
|
11
|
+
try {
|
|
12
|
+
const url = new URL(value);
|
|
13
|
+
if (!["http:", "https:"].includes(url.protocol)) {
|
|
14
|
+
throw new Error("http://またはhttps://のみ使用できます");
|
|
15
|
+
}
|
|
16
|
+
} catch (e) {
|
|
17
|
+
throw new Error(`qdrant_urlが無効です: ${e.message}`, { cause: e });
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
data_dir: (value) => {
|
|
21
|
+
const { resolve } = require("path");
|
|
22
|
+
const abs = resolve(value);
|
|
23
|
+
// 危険なシステムパスを拒否
|
|
24
|
+
const dangerousPaths = ["/etc", "/usr", "/bin", "/sbin", "/sys", "/proc", "/dev"];
|
|
25
|
+
if (dangerousPaths.some((p) => abs.startsWith(p))) {
|
|
26
|
+
throw new Error(`data_dirにシステムパスは指定できません: ${abs}`);
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
collection_name: (value) => {
|
|
30
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(value) || value.length > 256) {
|
|
31
|
+
throw new Error("collection_nameは英数字、ハイフン、アンダースコアのみ使用できます");
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
7
36
|
export function registerConfig(program) {
|
|
8
37
|
const cmd = program.command("config").description("Manage shabti configuration");
|
|
9
38
|
|
|
@@ -39,10 +68,29 @@ export function registerConfig(program) {
|
|
|
39
68
|
return;
|
|
40
69
|
}
|
|
41
70
|
|
|
71
|
+
if (CONFIG_VALIDATORS[key]) {
|
|
72
|
+
try {
|
|
73
|
+
CONFIG_VALIDATORS[key](value);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
error(e.message);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
42
80
|
const config = loadConfig();
|
|
43
|
-
|
|
81
|
+
let validatedValue = value;
|
|
82
|
+
if (key === "qdrant_url") {
|
|
83
|
+
try {
|
|
84
|
+
validatedValue = validateQdrantUrl(value);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
error(err.message);
|
|
87
|
+
process.exitCode = 1;
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
config[key] = validatedValue;
|
|
44
92
|
saveConfig(config);
|
|
45
|
-
success(`${key} = ${
|
|
93
|
+
success(`${key} = ${validatedValue}`);
|
|
46
94
|
});
|
|
47
95
|
|
|
48
96
|
cmd
|
package/src/commands/export.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { writeFileSync } from "fs";
|
|
2
|
+
import { resolve } from "path";
|
|
2
3
|
import { createEngine } from "../core/engine.js";
|
|
3
4
|
import { success, error } from "../utils/style.js";
|
|
4
5
|
import { parsePositiveInt } from "../utils/validate.js";
|
|
@@ -28,8 +29,9 @@ export function registerExport(program) {
|
|
|
28
29
|
const output = lines.join("\n");
|
|
29
30
|
|
|
30
31
|
if (opts.output) {
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
const absOutput = resolve(opts.output);
|
|
33
|
+
writeFileSync(absOutput, output + (lines.length ? "\n" : ""), "utf8");
|
|
34
|
+
success(`Exported ${entries.length} entries to ${absOutput}`);
|
|
33
35
|
} else {
|
|
34
36
|
if (output) console.log(output);
|
|
35
37
|
// Print summary to stderr so it doesn't pollute piped output
|
package/src/commands/import.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from "fs";
|
|
2
|
+
import { resolve } from "path";
|
|
2
3
|
import { createEngine } from "../core/engine.js";
|
|
3
4
|
import { success, error, info, warn } from "../utils/style.js";
|
|
4
5
|
|
|
@@ -11,12 +12,13 @@ export function registerImport(program) {
|
|
|
11
12
|
.option("--dry-run", "Parse and validate without storing")
|
|
12
13
|
.action(async (file, opts) => {
|
|
13
14
|
try {
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
const absFile = resolve(file);
|
|
16
|
+
if (!existsSync(absFile)) {
|
|
17
|
+
error(`File not found: ${absFile}`);
|
|
16
18
|
process.exitCode = 1;
|
|
17
19
|
return;
|
|
18
20
|
}
|
|
19
|
-
const raw = readFileSync(
|
|
21
|
+
const raw = readFileSync(absFile, "utf8");
|
|
20
22
|
const lines = raw
|
|
21
23
|
.split("\n")
|
|
22
24
|
.map((l) => l.trim())
|
package/src/commands/snapshot.js
CHANGED
|
@@ -28,6 +28,12 @@ export function registerSnapshot(program) {
|
|
|
28
28
|
.argument("<id>", "Snapshot ID to restore")
|
|
29
29
|
.action(async (id) => {
|
|
30
30
|
try {
|
|
31
|
+
// UUIDフォーマット検証
|
|
32
|
+
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
33
|
+
if (!UUID_REGEX.test(id)) {
|
|
34
|
+
error(`無効なスナップショットID: ${id}(UUID形式で指定してください)`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
31
37
|
const engine = createEngine();
|
|
32
38
|
engine.snapshotRestore(id);
|
|
33
39
|
success(`Restored from snapshot: ${id}`);
|
package/src/commands/status.js
CHANGED
|
@@ -19,8 +19,6 @@ export function registerStatus(program) {
|
|
|
19
19
|
console.log();
|
|
20
20
|
console.log(` ${chalk.cyan("Entries:")} ${status.entryCount}`);
|
|
21
21
|
console.log(` ${chalk.cyan("Tier:")} ${status.tier}`);
|
|
22
|
-
console.log(` ${chalk.cyan("Qdrant URL:")} ${status.qdrantUrl}`);
|
|
23
|
-
console.log(` ${chalk.cyan("Data Dir:")} ${status.dataDir}`);
|
|
24
22
|
console.log(` ${chalk.cyan("Model:")} ${status.modelId}`);
|
|
25
23
|
console.log();
|
|
26
24
|
}
|
package/src/core/engine.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { readFileSync, existsSync, mkdirSync, writeFileSync } from "fs";
|
|
1
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync, renameSync } from "fs";
|
|
2
2
|
import { createRequire } from "module";
|
|
3
3
|
import { homedir } from "os";
|
|
4
4
|
import { join } from "path";
|
|
5
|
+
import { validateQdrantUrl } from "../utils/validate.js";
|
|
5
6
|
|
|
6
7
|
const require = createRequire(import.meta.url);
|
|
7
8
|
|
|
@@ -17,7 +18,9 @@ const DEFAULT_CONFIG = {
|
|
|
17
18
|
/** Env var overrides (highest priority). */
|
|
18
19
|
function envOverrides() {
|
|
19
20
|
const o = {};
|
|
20
|
-
if (process.env.SHABTI_QDRANT_URL)
|
|
21
|
+
if (process.env.SHABTI_QDRANT_URL) {
|
|
22
|
+
o.qdrant_url = validateQdrantUrl(process.env.SHABTI_QDRANT_URL);
|
|
23
|
+
}
|
|
21
24
|
return o;
|
|
22
25
|
}
|
|
23
26
|
|
|
@@ -25,7 +28,11 @@ export function loadConfig() {
|
|
|
25
28
|
let config = DEFAULT_CONFIG;
|
|
26
29
|
if (existsSync(CONFIG_PATH)) {
|
|
27
30
|
try {
|
|
28
|
-
|
|
31
|
+
const saved = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
32
|
+
if (saved.qdrant_url !== undefined) {
|
|
33
|
+
saved.qdrant_url = validateQdrantUrl(saved.qdrant_url);
|
|
34
|
+
}
|
|
35
|
+
config = { ...config, ...saved };
|
|
29
36
|
} catch (_) {
|
|
30
37
|
// keep defaults
|
|
31
38
|
}
|
|
@@ -34,13 +41,15 @@ export function loadConfig() {
|
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
export function saveConfig(config) {
|
|
37
|
-
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
38
|
-
|
|
44
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
45
|
+
const tmpPath = CONFIG_PATH + ".tmp";
|
|
46
|
+
writeFileSync(tmpPath, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
47
|
+
renameSync(tmpPath, CONFIG_PATH);
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
export function createEngine(configOverrides = {}) {
|
|
42
51
|
const config = { ...loadConfig(), ...configOverrides };
|
|
43
|
-
mkdirSync(config.data_dir, { recursive: true });
|
|
52
|
+
mkdirSync(config.data_dir, { recursive: true, mode: 0o700 });
|
|
44
53
|
|
|
45
54
|
const { ShabtiEngine } = require("../../native.cjs");
|
|
46
55
|
|
package/src/core/session.js
CHANGED
|
@@ -6,6 +6,10 @@ import { error } from "../utils/style.js";
|
|
|
6
6
|
const DEFAULT_SYSTEM_PROMPT =
|
|
7
7
|
"You are Shabti, a helpful assistant accessed via CLI. Be concise and direct.";
|
|
8
8
|
|
|
9
|
+
// 既知の有効なOpenAIモデル名のパターン
|
|
10
|
+
const ALLOWED_MODEL_PATTERN = /^(gpt-[34][- .\w]+|o[12][\w-]*)$/;
|
|
11
|
+
const MAX_HISTORY_TURNS = 50; // 最大50ターン
|
|
12
|
+
|
|
9
13
|
export class ChatSession {
|
|
10
14
|
#client;
|
|
11
15
|
#messages;
|
|
@@ -49,9 +53,22 @@ export class ChatSession {
|
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
setModel(model) {
|
|
56
|
+
if (!ALLOWED_MODEL_PATTERN.test(model)) {
|
|
57
|
+
throw new Error(`無効なモデル名: ${model}`);
|
|
58
|
+
}
|
|
52
59
|
this.#model = model;
|
|
53
60
|
}
|
|
54
61
|
|
|
62
|
+
#trimHistory() {
|
|
63
|
+
// システムメッセージは保持、ユーザー/アシスタントのペアを最新N件に制限
|
|
64
|
+
const systemMessages = this.#messages.filter((m) => m.role === "system");
|
|
65
|
+
const conversationMessages = this.#messages.filter((m) => m.role !== "system");
|
|
66
|
+
|
|
67
|
+
if (conversationMessages.length > MAX_HISTORY_TURNS * 2) {
|
|
68
|
+
this.#messages = [...systemMessages, ...conversationMessages.slice(-MAX_HISTORY_TURNS * 2)];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
55
72
|
getModel() {
|
|
56
73
|
return this.#model;
|
|
57
74
|
}
|
|
@@ -84,6 +101,7 @@ export class ChatSession {
|
|
|
84
101
|
}
|
|
85
102
|
|
|
86
103
|
this.#messages.push({ role: "user", content: trimmed });
|
|
104
|
+
this.#trimHistory();
|
|
87
105
|
|
|
88
106
|
try {
|
|
89
107
|
process.stdout.write(chalk.cyan("shabti: "));
|
package/src/mcp/server.js
CHANGED
|
@@ -103,6 +103,26 @@ const TOOLS = [
|
|
|
103
103
|
properties: {},
|
|
104
104
|
},
|
|
105
105
|
},
|
|
106
|
+
{
|
|
107
|
+
name: "memory_health",
|
|
108
|
+
description:
|
|
109
|
+
"Run health checks on the memory engine (Qdrant connectivity, engine status, embedding model)",
|
|
110
|
+
inputSchema: {
|
|
111
|
+
type: "object",
|
|
112
|
+
properties: {},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "memory_get",
|
|
117
|
+
description: "Retrieve a specific memory entry by its UUID",
|
|
118
|
+
inputSchema: {
|
|
119
|
+
type: "object",
|
|
120
|
+
properties: {
|
|
121
|
+
id: { type: "string", description: "UUID of the memory entry to retrieve" },
|
|
122
|
+
},
|
|
123
|
+
required: ["id"],
|
|
124
|
+
},
|
|
125
|
+
},
|
|
106
126
|
];
|
|
107
127
|
|
|
108
128
|
const RESOURCES = [
|
|
@@ -206,7 +226,6 @@ async function handleToolsCall(id, params) {
|
|
|
206
226
|
entry_count: status.entryCount,
|
|
207
227
|
tier: status.tier,
|
|
208
228
|
model_id: status.modelId,
|
|
209
|
-
qdrant_url: status.qdrantUrl,
|
|
210
229
|
},
|
|
211
230
|
null,
|
|
212
231
|
2,
|
|
@@ -260,8 +279,10 @@ async function handleToolsCall(id, params) {
|
|
|
260
279
|
const queryObj = { text: normalizeText(query), limit };
|
|
261
280
|
if (args.namespace) queryObj.namespace = args.namespace;
|
|
262
281
|
const results = await eng.executeQuery(queryObj);
|
|
282
|
+
const formatMemoryEntry = (entry) =>
|
|
283
|
+
`[MEMORY_START id="${entry.id}"]\n${entry.content}\n[MEMORY_END]`;
|
|
263
284
|
const formatted = results.map((r) => ({
|
|
264
|
-
content: r
|
|
285
|
+
content: formatMemoryEntry(r),
|
|
265
286
|
score: r.score,
|
|
266
287
|
id: r.id,
|
|
267
288
|
namespace: r.namespace,
|
|
@@ -373,6 +394,78 @@ async function handleToolsCall(id, params) {
|
|
|
373
394
|
}
|
|
374
395
|
}
|
|
375
396
|
|
|
397
|
+
if (name === "memory_health") {
|
|
398
|
+
const checks = [];
|
|
399
|
+
const config = loadConfig();
|
|
400
|
+
const qdrantUrl = config.qdrant_url.replace(/:\d+$/, ":6333");
|
|
401
|
+
try {
|
|
402
|
+
const res = await fetch(`${qdrantUrl}/healthz`, {
|
|
403
|
+
signal: AbortSignal.timeout(3000),
|
|
404
|
+
});
|
|
405
|
+
checks.push({
|
|
406
|
+
name: "qdrant",
|
|
407
|
+
status: res.ok ? "ok" : "degraded",
|
|
408
|
+
message: res.ok ? "Reachable" : `HTTP ${res.status}`,
|
|
409
|
+
});
|
|
410
|
+
} catch (err) {
|
|
411
|
+
checks.push({ name: "qdrant", status: "error", message: err.message });
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (eng) {
|
|
415
|
+
try {
|
|
416
|
+
const status = eng.status();
|
|
417
|
+
checks.push({
|
|
418
|
+
name: "engine",
|
|
419
|
+
status: "ok",
|
|
420
|
+
message: `${status.entryCount} entries, tier: ${status.tier}`,
|
|
421
|
+
});
|
|
422
|
+
} catch (err) {
|
|
423
|
+
checks.push({ name: "engine", status: "error", message: err.message });
|
|
424
|
+
}
|
|
425
|
+
try {
|
|
426
|
+
const modelId = eng.modelId();
|
|
427
|
+
checks.push({ name: "embedding", status: "ok", message: modelId });
|
|
428
|
+
} catch (err) {
|
|
429
|
+
checks.push({ name: "embedding", status: "error", message: err.message });
|
|
430
|
+
}
|
|
431
|
+
} else {
|
|
432
|
+
checks.push({ name: "engine", status: "error", message: "Engine not available" });
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const healthy = checks.every((c) => c.status === "ok");
|
|
436
|
+
return respond(id, {
|
|
437
|
+
content: [
|
|
438
|
+
{
|
|
439
|
+
type: "text",
|
|
440
|
+
text: JSON.stringify({ healthy, checks }, null, 2),
|
|
441
|
+
},
|
|
442
|
+
],
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (name === "memory_get") {
|
|
447
|
+
if (!eng) {
|
|
448
|
+
return respondError(id, -32603, "Engine not available");
|
|
449
|
+
}
|
|
450
|
+
const entryId = args?.id;
|
|
451
|
+
if (!entryId) {
|
|
452
|
+
return respondError(id, -32602, "Missing required parameter: id");
|
|
453
|
+
}
|
|
454
|
+
try {
|
|
455
|
+
const entry = await eng.get(entryId);
|
|
456
|
+
return respond(id, {
|
|
457
|
+
content: [
|
|
458
|
+
{
|
|
459
|
+
type: "text",
|
|
460
|
+
text: JSON.stringify(entry, null, 2),
|
|
461
|
+
},
|
|
462
|
+
],
|
|
463
|
+
});
|
|
464
|
+
} catch (err) {
|
|
465
|
+
return respondError(id, -32603, err.message);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
376
469
|
respondError(id, -32601, `Unknown tool: ${name}`);
|
|
377
470
|
}
|
|
378
471
|
|
|
@@ -411,12 +504,17 @@ function handleResourcesRead(id, params) {
|
|
|
411
504
|
|
|
412
505
|
if (uri === "shabti://config") {
|
|
413
506
|
const config = loadConfig();
|
|
507
|
+
// 機密フィールドを除いた安全な設定情報のみ返す
|
|
508
|
+
const safeConfig = {
|
|
509
|
+
collection_name: config.collection_name,
|
|
510
|
+
// qdrant_url, data_dir は公開しない
|
|
511
|
+
};
|
|
414
512
|
return respond(id, {
|
|
415
513
|
contents: [
|
|
416
514
|
{
|
|
417
515
|
uri,
|
|
418
516
|
mimeType: "application/json",
|
|
419
|
-
text: JSON.stringify(
|
|
517
|
+
text: JSON.stringify(safeConfig),
|
|
420
518
|
},
|
|
421
519
|
],
|
|
422
520
|
});
|
|
@@ -457,7 +555,10 @@ async function handleRequest(line) {
|
|
|
457
555
|
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
458
556
|
rl.on("line", (line) => {
|
|
459
557
|
const trimmed = line.trim();
|
|
460
|
-
if (trimmed
|
|
558
|
+
if (trimmed && trimmed.length <= 10_000_000) {
|
|
559
|
+
// 10MB上限
|
|
560
|
+
handleRequest(trimmed);
|
|
561
|
+
}
|
|
461
562
|
});
|
|
462
563
|
|
|
463
564
|
async function shutdown() {
|
|
@@ -48,8 +48,12 @@ export function handleSlashCommand(cmd, args, session, rl, engine = null) {
|
|
|
48
48
|
|
|
49
49
|
case "/model":
|
|
50
50
|
if (args) {
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
try {
|
|
52
|
+
session.setModel(args);
|
|
53
|
+
success(`Model switched to ${chalk.cyan(args)}`);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
error(err.message);
|
|
56
|
+
}
|
|
53
57
|
} else {
|
|
54
58
|
info(`Current model: ${chalk.cyan(session.getModel())}`);
|
|
55
59
|
}
|
|
@@ -132,7 +136,8 @@ async function handleRecall(query, engine) {
|
|
|
132
136
|
console.log(chalk.dim(" No memories found."));
|
|
133
137
|
} else {
|
|
134
138
|
for (const r of results) {
|
|
135
|
-
|
|
139
|
+
const formatted = `[MEMORY_START id="${r.id}"]\n${r.content}\n[MEMORY_END]`;
|
|
140
|
+
console.log(` ${chalk.yellow(r.score.toFixed(4))} ${formatted}`);
|
|
136
141
|
}
|
|
137
142
|
}
|
|
138
143
|
} catch (err) {
|
package/src/utils/validate.js
CHANGED
|
@@ -52,3 +52,20 @@ export function parsePort(value, name) {
|
|
|
52
52
|
}
|
|
53
53
|
return n;
|
|
54
54
|
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* qdrant_urlのSSRF防止バリデーション
|
|
58
|
+
* http/httpsスキームのみ許可し、非ローカルホストの場合は警告を出す
|
|
59
|
+
*/
|
|
60
|
+
export function validateQdrantUrl(raw) {
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = new URL(raw);
|
|
64
|
+
} catch {
|
|
65
|
+
throw new Error(`無効なqdrant_url: ${raw}`);
|
|
66
|
+
}
|
|
67
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
68
|
+
throw new Error(`qdrant_urlにはhttp://またはhttps://のみ使用できます: ${raw}`);
|
|
69
|
+
}
|
|
70
|
+
return raw;
|
|
71
|
+
}
|