agentdb 3.1.0 → 3.3.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.
Files changed (35) hide show
  1. package/README.md +13 -0
  2. package/dist/src/controllers/ReasoningBank.d.ts +36 -0
  3. package/dist/src/controllers/ReasoningBank.d.ts.map +1 -1
  4. package/dist/src/controllers/ReasoningBank.js +123 -1
  5. package/dist/src/controllers/ReasoningBank.js.map +1 -1
  6. package/dist/src/controllers/ReflexionMemory.d.ts +75 -0
  7. package/dist/src/controllers/ReflexionMemory.d.ts.map +1 -1
  8. package/dist/src/controllers/ReflexionMemory.js +292 -10
  9. package/dist/src/controllers/ReflexionMemory.js.map +1 -1
  10. package/dist/src/controllers/SkillLibrary.d.ts +50 -0
  11. package/dist/src/controllers/SkillLibrary.d.ts.map +1 -1
  12. package/dist/src/controllers/SkillLibrary.js +220 -9
  13. package/dist/src/controllers/SkillLibrary.js.map +1 -1
  14. package/dist/src/controllers/crud-helpers.d.ts +45 -0
  15. package/dist/src/controllers/crud-helpers.d.ts.map +1 -0
  16. package/dist/src/controllers/crud-helpers.js +130 -0
  17. package/dist/src/controllers/crud-helpers.js.map +1 -0
  18. package/dist/src/controllers/episode-hybrid.d.ts +82 -0
  19. package/dist/src/controllers/episode-hybrid.d.ts.map +1 -0
  20. package/dist/src/controllers/episode-hybrid.js +140 -0
  21. package/dist/src/controllers/episode-hybrid.js.map +1 -0
  22. package/dist/src/mcp/agentdb-mcp-server.js +373 -47
  23. package/dist/src/mcp/agentdb-mcp-server.js.map +1 -1
  24. package/dist/src/search/conversation-retrieval.d.ts +61 -0
  25. package/dist/src/search/conversation-retrieval.d.ts.map +1 -0
  26. package/dist/src/search/conversation-retrieval.js +190 -0
  27. package/dist/src/search/conversation-retrieval.js.map +1 -0
  28. package/dist/src/search/cross-encoder.d.ts +17 -0
  29. package/dist/src/search/cross-encoder.d.ts.map +1 -0
  30. package/dist/src/search/cross-encoder.js +52 -0
  31. package/dist/src/search/cross-encoder.js.map +1 -0
  32. package/dist/src/security/mcp-policy.d.ts.map +1 -1
  33. package/dist/src/security/mcp-policy.js +13 -0
  34. package/dist/src/security/mcp-policy.js.map +1 -1
  35. package/package.json +6 -4
@@ -22,7 +22,8 @@ import { EmbeddingService } from '../controllers/EmbeddingService.js';
22
22
  import { BatchOperations } from '../optimizations/BatchOperations.js';
23
23
  import { ReasoningBank } from '../controllers/ReasoningBank.js';
24
24
  import { MCPToolCaches } from '../optimizations/ToolCache.js';
25
- import { validateId, validateTimestamp, validateSessionId, validateTaskString, validateNumericRange, validateArrayLength, validateBoolean, validateEnum, ValidationError, handleSecurityError, } from '../security/input-validation.js';
25
+ import { validateId, validateTimestamp, validateSessionId, validateTaskString, validateNumericRange, validateArrayLength, validateBoolean, validateEnum, ValidationError, handleSecurityError, sanitizeText, } from '../security/input-validation.js';
26
+ import { assertMetadata, assertTags, inTransaction, toRecordId, CrudValidationError } from '../controllers/crud-helpers.js';
26
27
  import * as path from 'path';
27
28
  import * as fs from 'fs';
28
29
  import { fileURLToPath } from 'url';
@@ -365,13 +366,15 @@ const tools = [
365
366
  },
366
367
  {
367
368
  name: 'agentdb_search',
368
- description: 'Semantic k-NN vector search using cosine similarity. Returns the most relevant results ranked by similarity score.',
369
+ description: 'Hybrid episode search: dense k-NN (cosine) fused with BM25 keyword ranking by reciprocal-rank fusion. Set hybrid=false for pure vector order.',
369
370
  inputSchema: {
370
371
  type: 'object',
371
372
  properties: {
372
373
  query: { type: 'string', description: 'Search query text' },
373
374
  k: { type: 'number', description: 'Number of results to return (max 100)', default: 10, minimum: 1, maximum: 100 },
374
375
  min_similarity: { type: 'number', description: 'Minimum similarity threshold (0-1)', default: 0.0 },
376
+ hybrid: { type: 'boolean', description: 'Fuse BM25 keyword ranking with the vector ranking (ADR-016)', default: true },
377
+ rerank: { type: 'boolean', description: 'Rerank the top candidates with a local cross-encoder (ADR-016; ~200 ms on CPU, 23 MB model on first use)', default: false },
375
378
  filters: {
376
379
  type: 'object',
377
380
  properties: {
@@ -406,6 +409,141 @@ const tools = [
406
409
  // ==========================================================================
407
410
  // FRONTIER MEMORY FEATURES (EXISTING)
408
411
  // ==========================================================================
412
+ {
413
+ name: 'agentdb_get',
414
+ description: 'Get one stored vector record (episode) by id. Returns null when it does not exist. The embedding is omitted unless include_embedding is true.',
415
+ inputSchema: {
416
+ type: 'object',
417
+ properties: {
418
+ id: { type: 'number', description: 'Record id returned by agentdb_insert' },
419
+ include_embedding: { type: 'boolean', description: 'Also return the stored embedding vector', default: false },
420
+ },
421
+ required: ['id'],
422
+ },
423
+ },
424
+ {
425
+ name: 'agentdb_list',
426
+ description: 'Page through stored vector records (episodes), newest first. Returns items plus the total count.',
427
+ inputSchema: {
428
+ type: 'object',
429
+ properties: {
430
+ session_id: { type: 'string', description: 'Only records from this session' },
431
+ limit: { type: 'number', description: 'Page size (1-500)', default: 50 },
432
+ offset: { type: 'number', description: 'Records to skip', default: 0 },
433
+ order: { type: 'string', enum: ['newest', 'oldest'], default: 'newest' },
434
+ },
435
+ },
436
+ },
437
+ {
438
+ name: 'agentdb_update',
439
+ description: 'Update a stored vector record in place, keeping its id. Changing text re-embeds it and refreshes the keyword index; tag or metadata changes do not re-embed. Metadata is merged (null removes a key) unless replace_metadata is true.',
440
+ inputSchema: {
441
+ type: 'object',
442
+ properties: {
443
+ id: { type: 'number', description: 'Record id' },
444
+ text: { type: 'string', description: 'New text content (re-embedded)' },
445
+ tags: { type: 'array', items: { type: 'string' }, description: 'Replacement tag list' },
446
+ metadata: { type: 'object', description: 'Metadata patch (merged by default)' },
447
+ replace_metadata: { type: 'boolean', description: 'Replace metadata instead of merging', default: false },
448
+ reward: { type: 'number', description: 'New reward value' },
449
+ success: { type: 'boolean', description: 'New success flag' },
450
+ },
451
+ required: ['id'],
452
+ },
453
+ },
454
+ {
455
+ name: 'agentdb_pattern_get',
456
+ description: 'Get one reasoning pattern by id.',
457
+ inputSchema: {
458
+ type: 'object',
459
+ properties: { pattern_id: { type: 'number', description: 'Pattern id' } },
460
+ required: ['pattern_id'],
461
+ },
462
+ },
463
+ {
464
+ name: 'agentdb_pattern_list',
465
+ description: 'Page through reasoning patterns, newest first, optionally filtered by task type.',
466
+ inputSchema: {
467
+ type: 'object',
468
+ properties: {
469
+ task_type: { type: 'string', description: 'Only patterns of this task type' },
470
+ limit: { type: 'number', default: 50 },
471
+ offset: { type: 'number', default: 0 },
472
+ },
473
+ },
474
+ },
475
+ {
476
+ name: 'agentdb_pattern_update',
477
+ description: 'Update a reasoning pattern in place. Changing taskType or approach re-embeds it. Usage statistics are not writable here (use agentdb_pattern_feedback).',
478
+ inputSchema: {
479
+ type: 'object',
480
+ properties: {
481
+ pattern_id: { type: 'number', description: 'Pattern id' },
482
+ taskType: { type: 'string' },
483
+ approach: { type: 'string' },
484
+ tags: { type: 'array', items: { type: 'string' } },
485
+ metadata: { type: 'object', description: 'Metadata patch (merged by default)' },
486
+ replace_metadata: { type: 'boolean', default: false },
487
+ },
488
+ required: ['pattern_id'],
489
+ },
490
+ },
491
+ {
492
+ name: 'agentdb_pattern_delete',
493
+ description: 'Delete a reasoning pattern, its embedding and its vector-index entry.',
494
+ inputSchema: {
495
+ type: 'object',
496
+ properties: { pattern_id: { type: 'number', description: 'Pattern id' } },
497
+ required: ['pattern_id'],
498
+ },
499
+ },
500
+ {
501
+ name: 'skill_get',
502
+ description: 'Get one skill by id.',
503
+ inputSchema: {
504
+ type: 'object',
505
+ properties: { skill_id: { type: 'number', description: 'Skill id' } },
506
+ required: ['skill_id'],
507
+ },
508
+ },
509
+ {
510
+ name: 'skill_list',
511
+ description: 'Page through skills, newest first.',
512
+ inputSchema: {
513
+ type: 'object',
514
+ properties: {
515
+ min_success_rate: { type: 'number', description: 'Only skills at or above this success rate' },
516
+ limit: { type: 'number', default: 50 },
517
+ offset: { type: 'number', default: 0 },
518
+ },
519
+ },
520
+ },
521
+ {
522
+ name: 'skill_update',
523
+ description: 'Update a skill in place. Changing name, description or signature re-embeds it; renaming onto an existing name is rejected. Usage statistics are not writable here (use agentdb_skill_feedback).',
524
+ inputSchema: {
525
+ type: 'object',
526
+ properties: {
527
+ skill_id: { type: 'number', description: 'Skill id' },
528
+ name: { type: 'string' },
529
+ description: { type: 'string' },
530
+ code: { type: 'string' },
531
+ signature: { type: 'object', description: '{ inputs: {...}, outputs: {...} }' },
532
+ metadata: { type: 'object', description: 'Metadata patch (merged by default)' },
533
+ replace_metadata: { type: 'boolean', default: false },
534
+ },
535
+ required: ['skill_id'],
536
+ },
537
+ },
538
+ {
539
+ name: 'skill_delete',
540
+ description: 'Delete a skill with its embedding, links and vector-index entry.',
541
+ inputSchema: {
542
+ type: 'object',
543
+ properties: { skill_id: { type: 'number', description: 'Skill id' } },
544
+ required: ['skill_id'],
545
+ },
546
+ },
409
547
  {
410
548
  name: 'reflexion_store',
411
549
  description: 'Store an episode with self-critique for reflexion-based learning',
@@ -972,6 +1110,157 @@ const tools = [
972
1110
  server.setRequestHandler(ListToolsRequestSchema, async () => {
973
1111
  return { tools: filterMcpTools(tools, toolPolicy) };
974
1112
  });
1113
+ /** Bulk-delete episodes together with their embeddings (FK cascades are off by default). */
1114
+ function deleteEpisodesWhere(condition, value) {
1115
+ const changes = inTransaction(db, () => {
1116
+ db.prepare(`DELETE FROM episode_embeddings WHERE episode_id IN (SELECT id FROM episodes WHERE ${condition})`).run(value);
1117
+ return db.prepare(`DELETE FROM episodes WHERE ${condition}`).run(value).changes;
1118
+ });
1119
+ if (changes > 0) {
1120
+ reflexion.clearCache();
1121
+ caches.stats.clear();
1122
+ }
1123
+ return changes;
1124
+ }
1125
+ /** Reject arguments a tool does not define, so a typo can never silently become a no-op update. */
1126
+ function rejectUnknownArgs(args, allowed, tool) {
1127
+ for (const key of Object.keys(args)) {
1128
+ if (!allowed.includes(key))
1129
+ throw new CrudValidationError(`${tool} does not accept "${key}"`, key);
1130
+ }
1131
+ }
1132
+ function optionalString(value, field, maxLength = 100_000) {
1133
+ if (value === undefined)
1134
+ return undefined;
1135
+ if (typeof value !== 'string')
1136
+ throw new CrudValidationError(`${field} must be a string`, field);
1137
+ if (value.length > maxLength)
1138
+ throw new CrudValidationError(`${field} exceeds ${maxLength} characters`, field);
1139
+ return value;
1140
+ }
1141
+ /** Dispatch for the ADR-017 CRUD tools. Returns a JSON-serialisable result. */
1142
+ async function handleCrudTool(name, args) {
1143
+ switch (name) {
1144
+ case 'agentdb_get': {
1145
+ rejectUnknownArgs(args, ['id', 'include_embedding'], name);
1146
+ const episode = reflexion.getEpisode(toRecordId(args.id), { includeEmbedding: args.include_embedding === true });
1147
+ if (!episode)
1148
+ return null;
1149
+ const { embedding, ...rest } = episode;
1150
+ return embedding ? { ...rest, embedding: Array.from(embedding) } : rest;
1151
+ }
1152
+ case 'agentdb_list': {
1153
+ rejectUnknownArgs(args, ['session_id', 'limit', 'offset', 'order'], name);
1154
+ const order = args.order === undefined ? 'newest' : validateEnum(args.order, 'order', ['newest', 'oldest']);
1155
+ return reflexion.listEpisodes({
1156
+ sessionId: args.session_id === undefined ? undefined : validateSessionId(args.session_id),
1157
+ limit: args.limit,
1158
+ offset: args.offset,
1159
+ order,
1160
+ });
1161
+ }
1162
+ case 'agentdb_update': {
1163
+ rejectUnknownArgs(args, ['id', 'text', 'tags', 'metadata', 'replace_metadata', 'reward', 'success'], name);
1164
+ // agentdb_insert stores the text as both task and input, so an update keeps them in step.
1165
+ const text = args.text === undefined ? undefined : requireText(args.text, 'text');
1166
+ const result = await reflexion.updateEpisode(toRecordId(args.id), {
1167
+ task: text,
1168
+ input: text,
1169
+ tags: args.tags === undefined ? undefined : assertTags(args.tags),
1170
+ metadata: args.metadata === undefined ? undefined : assertMetadata(args.metadata),
1171
+ reward: args.reward === undefined ? undefined : validateNumericRange(args.reward, 'reward', -1e6, 1e6),
1172
+ success: args.success === undefined ? undefined : validateBoolean(args.success, 'success'),
1173
+ }, { replaceMetadata: args.replace_metadata === true });
1174
+ if (result)
1175
+ caches.stats.clear();
1176
+ return result ? { updated: true, ...result } : { updated: false, reason: 'not found' };
1177
+ }
1178
+ case 'agentdb_pattern_get': {
1179
+ rejectUnknownArgs(args, ['pattern_id'], name);
1180
+ const pattern = reasoningBank.getPattern(toRecordId(args.pattern_id, 'pattern_id'));
1181
+ if (!pattern)
1182
+ return null;
1183
+ const { embedding: _omit, ...rest } = pattern;
1184
+ return rest;
1185
+ }
1186
+ case 'agentdb_pattern_list': {
1187
+ rejectUnknownArgs(args, ['task_type', 'limit', 'offset'], name);
1188
+ return reasoningBank.listPatterns({
1189
+ taskType: optionalString(args.task_type, 'task_type', 1000),
1190
+ limit: args.limit,
1191
+ offset: args.offset,
1192
+ });
1193
+ }
1194
+ case 'agentdb_pattern_update': {
1195
+ rejectUnknownArgs(args, ['pattern_id', 'taskType', 'approach', 'tags', 'metadata', 'replace_metadata'], name);
1196
+ const result = await reasoningBank.updatePattern(toRecordId(args.pattern_id, 'pattern_id'), {
1197
+ taskType: args.taskType === undefined ? undefined : requireText(args.taskType, 'taskType'),
1198
+ approach: args.approach === undefined ? undefined : requireText(args.approach, 'approach'),
1199
+ tags: args.tags === undefined ? undefined : assertTags(args.tags),
1200
+ metadata: args.metadata === undefined ? undefined : assertMetadata(args.metadata),
1201
+ }, { replaceMetadata: args.replace_metadata === true });
1202
+ if (result) {
1203
+ caches.patterns.clear();
1204
+ caches.stats.clear();
1205
+ }
1206
+ if (!result)
1207
+ return { updated: false, reason: 'not found' };
1208
+ const { embedding: _omit, ...pattern } = result.pattern;
1209
+ return { updated: true, ...result, pattern };
1210
+ }
1211
+ case 'agentdb_pattern_delete': {
1212
+ rejectUnknownArgs(args, ['pattern_id'], name);
1213
+ const deleted = reasoningBank.deletePattern(toRecordId(args.pattern_id, 'pattern_id'));
1214
+ if (deleted) {
1215
+ caches.patterns.clear();
1216
+ caches.stats.clear();
1217
+ }
1218
+ return { deleted };
1219
+ }
1220
+ case 'skill_get': {
1221
+ rejectUnknownArgs(args, ['skill_id'], name);
1222
+ return skills.getSkill(toRecordId(args.skill_id, 'skill_id'));
1223
+ }
1224
+ case 'skill_list': {
1225
+ rejectUnknownArgs(args, ['min_success_rate', 'limit', 'offset'], name);
1226
+ return skills.listSkills({
1227
+ minSuccessRate: args.min_success_rate === undefined ? undefined : validateNumericRange(args.min_success_rate, 'min_success_rate', 0, 1),
1228
+ limit: args.limit,
1229
+ offset: args.offset,
1230
+ });
1231
+ }
1232
+ case 'skill_update': {
1233
+ rejectUnknownArgs(args, ['skill_id', 'name', 'description', 'code', 'signature', 'metadata', 'replace_metadata'], name);
1234
+ const result = await skills.updateSkill(toRecordId(args.skill_id, 'skill_id'), {
1235
+ name: args.name === undefined ? undefined : requireText(args.name, 'name'),
1236
+ description: args.description === undefined ? undefined : requireText(args.description, 'description'),
1237
+ code: optionalString(args.code, 'code'),
1238
+ signature: args.signature === undefined ? undefined
1239
+ : assertMetadata(args.signature, 'signature'),
1240
+ metadata: args.metadata === undefined ? undefined : assertMetadata(args.metadata),
1241
+ }, { replaceMetadata: args.replace_metadata === true });
1242
+ if (result)
1243
+ caches.stats.clear();
1244
+ return result ? { updated: true, ...result } : { updated: false, reason: 'not found' };
1245
+ }
1246
+ case 'skill_delete': {
1247
+ rejectUnknownArgs(args, ['skill_id'], name);
1248
+ const deleted = skills.deleteSkill(toRecordId(args.skill_id, 'skill_id'));
1249
+ if (deleted)
1250
+ caches.stats.clear();
1251
+ return { deleted };
1252
+ }
1253
+ default:
1254
+ throw new CrudValidationError(`unknown CRUD tool ${name}`, 'name');
1255
+ }
1256
+ }
1257
+ /** Non-empty text up to `maxLength`, null bytes stripped. Deliberately no content heuristics: stored text may be code. */
1258
+ function requireText(value, field, maxLength = 100_000) {
1259
+ const text = sanitizeText(value, maxLength, field);
1260
+ if (!text.trim())
1261
+ throw new CrudValidationError(`${field} cannot be empty`, field);
1262
+ return text;
1263
+ }
975
1264
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
976
1265
  const { name, arguments: args } = request.params;
977
1266
  try {
@@ -989,7 +1278,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
989
1278
  }
990
1279
  // Initialize schema
991
1280
  initializeSchema(db);
992
- const stats = db.prepare('SELECT COUNT(*) as count FROM sqlite_master WHERE type="table"').get();
1281
+ const stats = db.prepare("SELECT COUNT(*) as count FROM sqlite_master WHERE type = 'table'").get();
993
1282
  return {
994
1283
  content: [
995
1284
  {
@@ -1004,10 +1293,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1004
1293
  };
1005
1294
  }
1006
1295
  case 'agentdb_insert': {
1007
- const text = args?.text;
1008
- const sessionId = args?.session_id || 'default';
1009
- const tags = args?.tags || [];
1010
- const metadata = args?.metadata || {};
1296
+ const text = requireText(args?.text, 'text');
1297
+ const sessionId = args?.session_id === undefined ? 'default' : validateSessionId(args.session_id);
1298
+ const tags = args?.tags === undefined ? [] : assertTags(args.tags);
1299
+ const metadata = args?.metadata === undefined ? {} : assertMetadata(args.metadata);
1011
1300
  const episodeId = await reflexion.storeEpisode({
1012
1301
  sessionId,
1013
1302
  task: text,
@@ -1037,19 +1326,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1037
1326
  case 'agentdb_insert_batch': {
1038
1327
  const items = validateArrayLength(args?.items ?? [], 'items', 0, MAX_INSERT_BATCH_ITEMS);
1039
1328
  const batchSize = args?.batch_size || 100;
1040
- const episodes = items.map((item) => ({
1041
- sessionId: item.session_id || 'default',
1042
- task: item.text,
1043
- reward: 1.0,
1044
- success: true,
1045
- input: item.text,
1046
- output: '',
1047
- critique: '',
1048
- latencyMs: 0,
1049
- tokensUsed: 0,
1050
- tags: item.tags || [],
1051
- metadata: item.metadata || {},
1052
- }));
1329
+ const episodes = items.map((item, i) => {
1330
+ if (!item || typeof item !== 'object')
1331
+ throw new ValidationError(`items[${i}] must be an object`, 'INVALID_TYPE', 'items');
1332
+ const text = requireText(item.text, `items[${i}].text`);
1333
+ return {
1334
+ sessionId: item.session_id === undefined ? 'default' : validateSessionId(item.session_id),
1335
+ task: text,
1336
+ reward: 1.0,
1337
+ success: true,
1338
+ input: text,
1339
+ output: '',
1340
+ critique: '',
1341
+ latencyMs: 0,
1342
+ tokensUsed: 0,
1343
+ tags: item.tags === undefined ? [] : assertTags(item.tags, `items[${i}].tags`),
1344
+ metadata: item.metadata === undefined ? {} : assertMetadata(item.metadata, `items[${i}].metadata`),
1345
+ };
1346
+ });
1053
1347
  const batchOpsConfig = new BatchOperations(db, embeddingService, {
1054
1348
  batchSize,
1055
1349
  parallelism: 4,
@@ -1069,13 +1363,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1069
1363
  };
1070
1364
  }
1071
1365
  case 'agentdb_search': {
1072
- const queryText = args?.query;
1366
+ const queryText = validateTaskString(args?.query, 'query');
1073
1367
  const k = Math.floor(validateNumericRange(args?.k ?? 10, 'k', 1, MAX_SEARCH_K));
1074
1368
  const minSimilarity = args?.min_similarity || 0.0;
1075
1369
  const filters = args?.filters;
1076
1370
  const query = {
1077
1371
  task: queryText,
1078
1372
  k,
1373
+ hybrid: args?.hybrid !== false,
1374
+ rerank: args?.rerank === true,
1079
1375
  };
1080
1376
  if (filters) {
1081
1377
  if (filters.min_reward !== undefined) {
@@ -1113,10 +1409,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1113
1409
  if (id !== undefined) {
1114
1410
  // Validate ID
1115
1411
  const validatedId = validateId(id, 'id');
1116
- // Delete single vector using parameterized query
1117
- const stmt = db.prepare('DELETE FROM episodes WHERE id = ?');
1118
- const result = stmt.run(validatedId);
1119
- deleted = result.changes;
1412
+ // Through the controller so the embedding, vector index and caches go with the row
1413
+ deleted = (await reflexion.deleteEpisode(validatedId)) ? 1 : 0;
1120
1414
  }
1121
1415
  else if (filters) {
1122
1416
  // Bulk delete with validated filters
@@ -1124,17 +1418,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1124
1418
  // Validate session_id
1125
1419
  const validatedSessionId = validateSessionId(filters.session_id);
1126
1420
  // Use parameterized query
1127
- const stmt = db.prepare('DELETE FROM episodes WHERE session_id = ?');
1128
- const result = stmt.run(validatedSessionId);
1129
- deleted = result.changes;
1421
+ deleted = deleteEpisodesWhere('session_id = ?', validatedSessionId);
1130
1422
  }
1131
1423
  else if (filters.before_timestamp) {
1132
1424
  // Validate timestamp
1133
1425
  const validatedTimestamp = validateTimestamp(filters.before_timestamp, 'before_timestamp');
1134
1426
  // Use parameterized query
1135
- const stmt = db.prepare('DELETE FROM episodes WHERE ts < ?');
1136
- const result = stmt.run(validatedTimestamp);
1137
- deleted = result.changes;
1427
+ deleted = deleteEpisodesWhere('ts < ?', validatedTimestamp);
1138
1428
  }
1139
1429
  else {
1140
1430
  throw new ValidationError('Invalid or missing filter criteria', 'INVALID_FILTER');
@@ -1168,6 +1458,29 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1168
1458
  }
1169
1459
  }
1170
1460
  // ======================================================================
1461
+ // CRUD: get / list / update / delete (ADR-017)
1462
+ // ======================================================================
1463
+ case 'agentdb_get':
1464
+ case 'agentdb_list':
1465
+ case 'agentdb_update':
1466
+ case 'agentdb_pattern_get':
1467
+ case 'agentdb_pattern_list':
1468
+ case 'agentdb_pattern_update':
1469
+ case 'agentdb_pattern_delete':
1470
+ case 'skill_get':
1471
+ case 'skill_list':
1472
+ case 'skill_update':
1473
+ case 'skill_delete': {
1474
+ try {
1475
+ const result = await handleCrudTool(name, (args ?? {}));
1476
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
1477
+ }
1478
+ catch (error) {
1479
+ const message = error instanceof CrudValidationError ? error.message : handleSecurityError(error);
1480
+ return { content: [{ type: 'text', text: `❌ ${name} failed: ${message}` }], isError: true };
1481
+ }
1482
+ }
1483
+ // ======================================================================
1171
1484
  // FRONTIER MEMORY FEATURES (EXISTING)
1172
1485
  // ======================================================================
1173
1486
  case 'reflexion_store': {
@@ -2273,30 +2586,40 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2273
2586
  };
2274
2587
  }
2275
2588
  case 'agentdb_delete_batch': {
2276
- const ids = args?.ids || [];
2589
+ const ids = validateArrayLength(args?.ids ?? [], 'ids', 0, MAX_INSERT_BATCH_ITEMS);
2277
2590
  const requestedTable = args?.table || 'episodes';
2278
- const allowedTables = new Set(['episodes', 'reasoning_patterns']);
2279
- if (!allowedTables.has(requestedTable)) {
2280
- throw new Error(`Invalid table: ${requestedTable}`);
2591
+ // Each table with the embedding table that must be cleaned alongside it (FK cascades are off by default)
2592
+ const embeddingTables = {
2593
+ episodes: ['episode_embeddings', 'episode_id'],
2594
+ reasoning_patterns: ['pattern_embeddings', 'pattern_id'],
2595
+ };
2596
+ if (!Object.prototype.hasOwnProperty.call(embeddingTables, requestedTable)) {
2597
+ throw new ValidationError(`Invalid table: ${requestedTable}`, 'INVALID_TABLE', 'table');
2281
2598
  }
2282
2599
  if (ids.length === 0) {
2283
2600
  return { content: [{ type: 'text', text: '⚠️ No IDs supplied — nothing to delete.' }] };
2284
2601
  }
2285
- // Validate each id is a positive integer (defense in depth)
2286
- const safeIds = ids.map((raw) => {
2287
- const n = Number(raw);
2288
- if (!Number.isInteger(n) || n <= 0)
2289
- throw new Error(`Invalid id: ${raw}`);
2290
- return n;
2291
- });
2602
+ const safeIds = ids.map((raw, i) => toRecordId(raw, `ids[${i}]`));
2292
2603
  const placeholders = safeIds.map(() => '?').join(',');
2293
- // requestedTable is whitelist-checked above; parameterising values via ?.
2294
- const stmt = db.prepare(`DELETE FROM ${requestedTable} WHERE id IN (${placeholders})`);
2295
- const result = stmt.run(...safeIds);
2604
+ const [embTable, embColumn] = embeddingTables[requestedTable];
2605
+ // Table names come from the fixed map above; values are bound parameters.
2606
+ const changes = inTransaction(db, () => {
2607
+ db.prepare(`DELETE FROM ${embTable} WHERE ${embColumn} IN (${placeholders})`).run(...safeIds);
2608
+ return db.prepare(`DELETE FROM ${requestedTable} WHERE id IN (${placeholders})`).run(...safeIds).changes;
2609
+ });
2610
+ if (changes > 0) {
2611
+ if (requestedTable === 'episodes')
2612
+ reflexion.clearCache();
2613
+ else {
2614
+ reasoningBank.clearCache();
2615
+ caches.patterns.clear();
2616
+ }
2617
+ caches.stats.clear();
2618
+ }
2296
2619
  return {
2297
2620
  content: [{
2298
2621
  type: 'text',
2299
- text: `🗑️ Batch delete from \`${requestedTable}\`: ${result.changes}/${safeIds.length} rows removed (atomic).`,
2622
+ text: `🗑️ Batch delete from \`${requestedTable}\`: ${changes}/${safeIds.length} rows removed (atomic).`,
2300
2623
  }],
2301
2624
  };
2302
2625
  }
@@ -2321,11 +2644,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2321
2644
  }
2322
2645
  }
2323
2646
  catch (error) {
2647
+ if (!(error instanceof ValidationError))
2648
+ console.error(`[agentdb-mcp] ${name} failed:`, error);
2324
2649
  return {
2325
2650
  content: [
2326
2651
  {
2327
2652
  type: 'text',
2328
- text: `❌ Error: ${error.message}\n${error.stack || ''}`,
2653
+ // The stack trace stays in the server log; clients only see the message.
2654
+ text: `❌ Error: ${error?.message ?? String(error)}`,
2329
2655
  },
2330
2656
  ],
2331
2657
  isError: true,