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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shabti",
3
- "version": "2.11.0",
3
+ "version": "2.12.1",
4
4
  "description": "Agent Memory OS — semantic memory for AI agents",
5
5
  "type": "module",
6
6
  "main": "native.cjs",
@@ -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
- constructor() {
14
- this.tasks = new Map();
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.tasks.set(task.id, task);
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.tasks.get(id) || null;
59
+ return this.#tasks.get(id) || null;
32
60
  }
33
61
 
34
62
  set(id, task) {
35
- this.tasks.set(id, task);
63
+ this.#tasks.set(id, task);
36
64
  }
37
65
 
38
66
  clear() {
39
- this.tasks.clear();
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
- req.on("data", (c) => chunks.push(c));
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`);
@@ -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
- config[key] = value;
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} = ${value}`);
93
+ success(`${key} = ${validatedValue}`);
46
94
  });
47
95
 
48
96
  cmd
@@ -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
- writeFileSync(opts.output, output + (lines.length ? "\n" : ""), "utf8");
32
- success(`Exported ${entries.length} entries to ${opts.output}`);
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
@@ -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
- if (!existsSync(file)) {
15
- error(`File not found: ${file}`);
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(file, "utf8");
21
+ const raw = readFileSync(absFile, "utf8");
20
22
  const lines = raw
21
23
  .split("\n")
22
24
  .map((l) => l.trim())
@@ -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}`);
@@ -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
  }
@@ -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) o.qdrant_url = 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
- config = { ...config, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) };
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
- writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
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
 
@@ -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.content,
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(config),
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) handleRequest(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
- session.setModel(args);
52
- success(`Model switched to ${chalk.cyan(args)}`);
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
- console.log(` ${chalk.yellow(r.score.toFixed(4))} ${r.content}`);
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) {
@@ -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
+ }