@troykelly/openclaw-projects 0.0.10 → 0.0.12

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 (96) hide show
  1. package/dist/config.d.ts +30 -1
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js +28 -1
  4. package/dist/config.js.map +1 -1
  5. package/dist/hooks.d.ts.map +1 -1
  6. package/dist/hooks.js +17 -3
  7. package/dist/hooks.js.map +1 -1
  8. package/dist/register-openclaw.d.ts +3 -1
  9. package/dist/register-openclaw.d.ts.map +1 -1
  10. package/dist/register-openclaw.js +611 -55
  11. package/dist/register-openclaw.js.map +1 -1
  12. package/dist/tools/contacts.d.ts +6 -6
  13. package/dist/tools/contacts.d.ts.map +1 -1
  14. package/dist/tools/contacts.js +7 -7
  15. package/dist/tools/contacts.js.map +1 -1
  16. package/dist/tools/context-search.d.ts +79 -0
  17. package/dist/tools/context-search.d.ts.map +1 -0
  18. package/dist/tools/context-search.js +265 -0
  19. package/dist/tools/context-search.js.map +1 -0
  20. package/dist/tools/email-send.d.ts.map +1 -1
  21. package/dist/tools/email-send.js +1 -14
  22. package/dist/tools/email-send.js.map +1 -1
  23. package/dist/tools/entity-links.d.ts +117 -0
  24. package/dist/tools/entity-links.d.ts.map +1 -0
  25. package/dist/tools/entity-links.js +446 -0
  26. package/dist/tools/entity-links.js.map +1 -0
  27. package/dist/tools/index.d.ts +4 -0
  28. package/dist/tools/index.d.ts.map +1 -1
  29. package/dist/tools/index.js +8 -0
  30. package/dist/tools/index.js.map +1 -1
  31. package/dist/tools/memory-forget.js +5 -5
  32. package/dist/tools/memory-forget.js.map +1 -1
  33. package/dist/tools/memory-recall.d.ts +28 -0
  34. package/dist/tools/memory-recall.d.ts.map +1 -1
  35. package/dist/tools/memory-recall.js +44 -4
  36. package/dist/tools/memory-recall.js.map +1 -1
  37. package/dist/tools/memory-store.d.ts +57 -0
  38. package/dist/tools/memory-store.d.ts.map +1 -1
  39. package/dist/tools/memory-store.js +29 -2
  40. package/dist/tools/memory-store.js.map +1 -1
  41. package/dist/tools/message-search.d.ts +1 -1
  42. package/dist/tools/message-search.d.ts.map +1 -1
  43. package/dist/tools/message-search.js +20 -2
  44. package/dist/tools/message-search.js.map +1 -1
  45. package/dist/tools/notes.d.ts +2 -2
  46. package/dist/tools/project-search.d.ts +92 -0
  47. package/dist/tools/project-search.d.ts.map +1 -0
  48. package/dist/tools/project-search.js +160 -0
  49. package/dist/tools/project-search.js.map +1 -0
  50. package/dist/tools/relationships.js +1 -1
  51. package/dist/tools/relationships.js.map +1 -1
  52. package/dist/tools/skill-store.d.ts +12 -12
  53. package/dist/tools/threads.d.ts +2 -2
  54. package/dist/tools/threads.d.ts.map +1 -1
  55. package/dist/tools/threads.js +30 -6
  56. package/dist/tools/threads.js.map +1 -1
  57. package/dist/tools/todo-search.d.ts +95 -0
  58. package/dist/tools/todo-search.d.ts.map +1 -0
  59. package/dist/tools/todo-search.js +164 -0
  60. package/dist/tools/todo-search.js.map +1 -0
  61. package/dist/types/openclaw-api.d.ts +15 -0
  62. package/dist/types/openclaw-api.d.ts.map +1 -1
  63. package/dist/utils/auto-linker.d.ts +66 -0
  64. package/dist/utils/auto-linker.d.ts.map +1 -0
  65. package/dist/utils/auto-linker.js +354 -0
  66. package/dist/utils/auto-linker.js.map +1 -0
  67. package/dist/utils/geo.d.ts +24 -0
  68. package/dist/utils/geo.d.ts.map +1 -0
  69. package/dist/utils/geo.js +38 -0
  70. package/dist/utils/geo.js.map +1 -0
  71. package/dist/utils/inbound-gate.d.ts +85 -0
  72. package/dist/utils/inbound-gate.d.ts.map +1 -0
  73. package/dist/utils/inbound-gate.js +133 -0
  74. package/dist/utils/inbound-gate.js.map +1 -0
  75. package/dist/utils/injection-protection.d.ts +81 -0
  76. package/dist/utils/injection-protection.d.ts.map +1 -0
  77. package/dist/utils/injection-protection.js +179 -0
  78. package/dist/utils/injection-protection.js.map +1 -0
  79. package/dist/utils/nominatim.d.ts +18 -0
  80. package/dist/utils/nominatim.d.ts.map +1 -0
  81. package/dist/utils/nominatim.js +56 -0
  82. package/dist/utils/nominatim.js.map +1 -0
  83. package/dist/utils/rate-limiter.d.ts +81 -0
  84. package/dist/utils/rate-limiter.d.ts.map +1 -0
  85. package/dist/utils/rate-limiter.js +188 -0
  86. package/dist/utils/rate-limiter.js.map +1 -0
  87. package/dist/utils/spam-filter.d.ts +79 -0
  88. package/dist/utils/spam-filter.d.ts.map +1 -0
  89. package/dist/utils/spam-filter.js +237 -0
  90. package/dist/utils/spam-filter.js.map +1 -0
  91. package/dist/utils/token-budget.d.ts +68 -0
  92. package/dist/utils/token-budget.d.ts.map +1 -0
  93. package/dist/utils/token-budget.js +142 -0
  94. package/dist/utils/token-budget.js.map +1 -0
  95. package/openclaw.plugin.json +3 -3
  96. package/package.json +1 -1
@@ -8,15 +8,19 @@
8
8
  * - CLI registered via `api.registerCli()`
9
9
  */
10
10
  import { ZodError } from 'zod';
11
- import { validateRawConfig, resolveConfigSecretsSync, redactConfig } from './config.js';
12
- import { createLogger } from './logger.js';
13
11
  import { createApiClient } from './api-client.js';
12
+ import { redactConfig, resolveConfigSecretsSync, validateRawConfig } from './config.js';
14
13
  import { extractContext, getUserScopeKey } from './context.js';
15
- import { createSkillStorePutTool, createSkillStoreGetTool, createSkillStoreListTool, createSkillStoreDeleteTool, createSkillStoreSearchTool, createSkillStoreCollectionsTool, createSkillStoreAggregateTool, } from './tools/index.js';
16
- import { createGatewayMethods, registerGatewayRpcMethods } from './gateway/rpc-methods.js';
17
14
  import { createOAuthGatewayMethods, registerOAuthGatewayRpcMethods } from './gateway/oauth-rpc-methods.js';
18
- import { createNotificationService } from './services/notification-service.js';
15
+ import { createGatewayMethods, registerGatewayRpcMethods } from './gateway/rpc-methods.js';
19
16
  import { createAutoCaptureHook, createGraphAwareRecallHook } from './hooks.js';
17
+ import { createLogger } from './logger.js';
18
+ import { detectInjectionPatterns, sanitizeMetadataField, sanitizeMessageForContext, wrapExternalMessage } from './utils/injection-protection.js';
19
+ import { haversineDistanceKm, computeGeoScore, blendScores } from './utils/geo.js';
20
+ import { reverseGeocode } from './utils/nominatim.js';
21
+ import { createNotificationService } from './services/notification-service.js';
22
+ import { autoLinkInboundMessage } from './utils/auto-linker.js';
23
+ import { createProjectSearchTool, createContextSearchTool, createSkillStoreAggregateTool, createSkillStoreCollectionsTool, createSkillStoreDeleteTool, createSkillStoreGetTool, createSkillStoreListTool, createSkillStorePutTool, createSkillStoreSearchTool, createLinksSetTool, createLinksQueryTool, createLinksRemoveTool, } from './tools/index.js';
20
24
  /**
21
25
  * Convert internal ToolResult format to AgentToolResult format expected by OpenClaw Gateway.
22
26
  *
@@ -73,6 +77,28 @@ const memoryRecallSchema = {
73
77
  description: 'Scope search to a specific relationship between contacts',
74
78
  format: 'uuid',
75
79
  },
80
+ location: {
81
+ type: 'object',
82
+ description: 'Current location for geo-aware recall ranking',
83
+ properties: {
84
+ lat: { type: 'number', minimum: -90, maximum: 90 },
85
+ lng: { type: 'number', minimum: -180, maximum: 180 },
86
+ },
87
+ required: ['lat', 'lng'],
88
+ },
89
+ location_radius_km: {
90
+ type: 'number',
91
+ description: 'Filter memories within this radius (km) of the given location',
92
+ minimum: 0.1,
93
+ maximum: 100,
94
+ },
95
+ location_weight: {
96
+ type: 'number',
97
+ description: 'Weight for geo scoring (0 = content only, 1 = geo only)',
98
+ minimum: 0,
99
+ maximum: 1,
100
+ default: 0.3,
101
+ },
76
102
  },
77
103
  required: ['query'],
78
104
  };
@@ -121,6 +147,35 @@ const memoryStoreSchema = {
121
147
  description: 'Scope memory to a specific relationship between contacts',
122
148
  format: 'uuid',
123
149
  },
150
+ location: {
151
+ type: 'object',
152
+ description: 'Geographic location to associate with this memory',
153
+ properties: {
154
+ lat: {
155
+ type: 'number',
156
+ description: 'Latitude (-90 to 90)',
157
+ minimum: -90,
158
+ maximum: 90,
159
+ },
160
+ lng: {
161
+ type: 'number',
162
+ description: 'Longitude (-180 to 180)',
163
+ minimum: -180,
164
+ maximum: 180,
165
+ },
166
+ address: {
167
+ type: 'string',
168
+ description: 'Street address (max 500 chars)',
169
+ maxLength: 500,
170
+ },
171
+ place_label: {
172
+ type: 'string',
173
+ description: 'Short place name (max 200 chars)',
174
+ maxLength: 200,
175
+ },
176
+ },
177
+ required: ['lat', 'lng'],
178
+ },
124
179
  },
125
180
  required: ['text'],
126
181
  };
@@ -213,18 +268,22 @@ const todoListSchema = {
213
268
  description: 'Filter by project ID',
214
269
  format: 'uuid',
215
270
  },
216
- status: {
217
- type: 'string',
218
- description: 'Filter by todo status',
219
- enum: ['pending', 'in_progress', 'completed', 'all'],
220
- default: 'pending',
271
+ completed: {
272
+ type: 'boolean',
273
+ description: 'Filter by completion status. true = completed only, false = active only, omit = all.',
221
274
  },
222
275
  limit: {
223
276
  type: 'integer',
224
277
  description: 'Maximum number of todos to return',
225
278
  minimum: 1,
226
- maximum: 100,
227
- default: 20,
279
+ maximum: 200,
280
+ default: 50,
281
+ },
282
+ offset: {
283
+ type: 'integer',
284
+ description: 'Offset for pagination',
285
+ minimum: 0,
286
+ default: 0,
228
287
  },
229
288
  },
230
289
  };
@@ -278,6 +337,95 @@ const todoCompleteSchema = {
278
337
  },
279
338
  required: ['todoId'],
280
339
  };
340
+ /**
341
+ * Todo search tool JSON Schema (Issue #1216)
342
+ */
343
+ const todoSearchSchema = {
344
+ type: 'object',
345
+ properties: {
346
+ query: {
347
+ type: 'string',
348
+ description: 'Natural language search query for finding work items',
349
+ minLength: 1,
350
+ maxLength: 1000,
351
+ },
352
+ limit: {
353
+ type: 'integer',
354
+ description: 'Maximum number of results to return',
355
+ minimum: 1,
356
+ maximum: 50,
357
+ default: 10,
358
+ },
359
+ kind: {
360
+ type: 'string',
361
+ description: 'Filter by work item kind',
362
+ enum: ['task', 'project', 'initiative', 'epic', 'issue'],
363
+ },
364
+ status: {
365
+ type: 'string',
366
+ description: 'Filter by status (e.g., open, completed, in_progress)',
367
+ maxLength: 50,
368
+ },
369
+ },
370
+ required: ['query'],
371
+ };
372
+ /**
373
+ * Project search tool JSON Schema (Issue #1217)
374
+ */
375
+ const projectSearchSchema = {
376
+ type: 'object',
377
+ properties: {
378
+ query: {
379
+ type: 'string',
380
+ description: 'Natural language search query for finding projects',
381
+ minLength: 1,
382
+ maxLength: 1000,
383
+ },
384
+ limit: {
385
+ type: 'integer',
386
+ description: 'Maximum number of results to return',
387
+ minimum: 1,
388
+ maximum: 50,
389
+ default: 10,
390
+ },
391
+ status: {
392
+ type: 'string',
393
+ description: 'Filter by project status',
394
+ enum: ['active', 'completed', 'archived'],
395
+ },
396
+ },
397
+ required: ['query'],
398
+ };
399
+ /**
400
+ * Context search tool JSON Schema (Issue #1219)
401
+ */
402
+ const contextSearchSchema = {
403
+ type: 'object',
404
+ properties: {
405
+ query: {
406
+ type: 'string',
407
+ description: 'Natural language search query across memories, todos, projects, and messages',
408
+ minLength: 1,
409
+ maxLength: 1000,
410
+ },
411
+ entity_types: {
412
+ type: 'array',
413
+ description: 'Filter to specific entity types. Defaults to all (memory, todo, project, message).',
414
+ items: {
415
+ type: 'string',
416
+ enum: ['memory', 'todo', 'project', 'message'],
417
+ },
418
+ },
419
+ limit: {
420
+ type: 'integer',
421
+ description: 'Maximum number of results to return',
422
+ minimum: 1,
423
+ maximum: 50,
424
+ default: 10,
425
+ },
426
+ },
427
+ required: ['query'],
428
+ };
281
429
  /**
282
430
  * Contact search tool JSON Schema
283
431
  */
@@ -877,6 +1025,90 @@ const skillStoreAggregateSchema = {
877
1025
  },
878
1026
  required: ['skill_id', 'operation'],
879
1027
  };
1028
+ /**
1029
+ * Entity linking tool JSON Schemas (Issue #1220)
1030
+ */
1031
+ const linksSetSchema = {
1032
+ type: 'object',
1033
+ properties: {
1034
+ source_type: {
1035
+ type: 'string',
1036
+ description: 'Type of the source entity',
1037
+ enum: ['memory', 'todo', 'project', 'contact'],
1038
+ },
1039
+ source_id: {
1040
+ type: 'string',
1041
+ description: 'UUID of the source entity',
1042
+ format: 'uuid',
1043
+ },
1044
+ target_type: {
1045
+ type: 'string',
1046
+ description: 'Type of the target entity or external reference',
1047
+ enum: ['memory', 'todo', 'project', 'contact', 'github_issue', 'url'],
1048
+ },
1049
+ target_ref: {
1050
+ type: 'string',
1051
+ description: 'Reference to the target: UUID for internal entities, "owner/repo#N" for GitHub issues, URL for urls',
1052
+ minLength: 1,
1053
+ },
1054
+ label: {
1055
+ type: 'string',
1056
+ description: 'Optional label describing the link (e.g., "spawned from", "tracks", "related to")',
1057
+ maxLength: 100,
1058
+ },
1059
+ },
1060
+ required: ['source_type', 'source_id', 'target_type', 'target_ref'],
1061
+ };
1062
+ const linksQuerySchema = {
1063
+ type: 'object',
1064
+ properties: {
1065
+ entity_type: {
1066
+ type: 'string',
1067
+ description: 'Type of the entity to query links for',
1068
+ enum: ['memory', 'todo', 'project', 'contact'],
1069
+ },
1070
+ entity_id: {
1071
+ type: 'string',
1072
+ description: 'UUID of the entity to query links for',
1073
+ format: 'uuid',
1074
+ },
1075
+ link_types: {
1076
+ type: 'array',
1077
+ description: 'Optional filter to only return links to specific entity types',
1078
+ items: {
1079
+ type: 'string',
1080
+ enum: ['memory', 'todo', 'project', 'contact', 'github_issue', 'url'],
1081
+ },
1082
+ },
1083
+ },
1084
+ required: ['entity_type', 'entity_id'],
1085
+ };
1086
+ const linksRemoveSchema = {
1087
+ type: 'object',
1088
+ properties: {
1089
+ source_type: {
1090
+ type: 'string',
1091
+ description: 'Type of the source entity',
1092
+ enum: ['memory', 'todo', 'project', 'contact'],
1093
+ },
1094
+ source_id: {
1095
+ type: 'string',
1096
+ description: 'UUID of the source entity',
1097
+ format: 'uuid',
1098
+ },
1099
+ target_type: {
1100
+ type: 'string',
1101
+ description: 'Type of the target entity or external reference',
1102
+ enum: ['memory', 'todo', 'project', 'contact', 'github_issue', 'url'],
1103
+ },
1104
+ target_ref: {
1105
+ type: 'string',
1106
+ description: 'Reference to the target',
1107
+ minLength: 1,
1108
+ },
1109
+ },
1110
+ required: ['source_type', 'source_id', 'target_type', 'target_ref'],
1111
+ };
880
1112
  /**
881
1113
  * Create tool execution handlers
882
1114
  */
@@ -884,9 +1116,11 @@ function createToolHandlers(state) {
884
1116
  const { config, logger, apiClient, userId } = state;
885
1117
  return {
886
1118
  async memory_recall(params) {
887
- const { query, limit = config.maxRecallMemories, category, tags, relationship_id, } = params;
1119
+ const { query, limit = config.maxRecallMemories, category, tags, relationship_id, location, location_radius_km, location_weight, } = params;
888
1120
  try {
889
- const queryParams = new URLSearchParams({ q: query, limit: String(limit) });
1121
+ // Over-fetch when location is provided to allow geo re-ranking
1122
+ const apiLimit = location ? Math.min(limit * 3, 60) : limit;
1123
+ const queryParams = new URLSearchParams({ q: query, limit: String(apiLimit) });
890
1124
  if (category)
891
1125
  queryParams.set('memory_type', category);
892
1126
  if (tags && tags.length > 0)
@@ -897,7 +1131,36 @@ function createToolHandlers(state) {
897
1131
  if (!response.success) {
898
1132
  return { success: false, error: response.error.message };
899
1133
  }
900
- const memories = response.data.results ?? [];
1134
+ let memories = (response.data.results ?? []).map((m) => ({
1135
+ ...m,
1136
+ category: m.type === 'note' ? 'other' : m.type,
1137
+ score: m.similarity,
1138
+ }));
1139
+ // Apply geo re-ranking if location is provided
1140
+ if (location) {
1141
+ const { lat: qLat, lng: qLng } = location;
1142
+ const weight = location_weight ?? 0.3;
1143
+ // Filter by radius if specified
1144
+ if (location_radius_km !== undefined) {
1145
+ memories = memories.filter((m) => {
1146
+ if (m.lat == null || m.lng == null)
1147
+ return false;
1148
+ return haversineDistanceKm(qLat, qLng, m.lat, m.lng) <= location_radius_km;
1149
+ });
1150
+ }
1151
+ // Compute blended scores and re-sort
1152
+ memories = memories
1153
+ .map((m) => {
1154
+ const contentScore = m.score ?? 0;
1155
+ let geoScore = 0.5;
1156
+ if (m.lat != null && m.lng != null) {
1157
+ geoScore = computeGeoScore(haversineDistanceKm(qLat, qLng, m.lat, m.lng));
1158
+ }
1159
+ return { ...m, score: blendScores(contentScore, geoScore, weight) };
1160
+ })
1161
+ .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
1162
+ .slice(0, limit);
1163
+ }
901
1164
  const content = memories.length > 0 ? memories.map((m) => `- [${m.type}] ${m.content}`).join('\n') : 'No relevant memories found.';
902
1165
  return {
903
1166
  success: true,
@@ -914,7 +1177,7 @@ function createToolHandlers(state) {
914
1177
  },
915
1178
  async memory_store(params) {
916
1179
  // Accept 'text' (OpenClaw native) or 'content' (backwards compat)
917
- const { text, content: contentAlias, category = 'other', importance = 0.7, tags, relationship_id, } = params;
1180
+ const { text, content: contentAlias, category = 'other', importance = 0.7, tags, relationship_id, location, } = params;
918
1181
  const memoryText = text || contentAlias;
919
1182
  if (!memoryText) {
920
1183
  return { success: false, error: 'text is required' };
@@ -931,6 +1194,24 @@ function createToolHandlers(state) {
931
1194
  payload.tags = tags;
932
1195
  if (relationship_id)
933
1196
  payload.relationship_id = relationship_id;
1197
+ if (location) {
1198
+ payload.lat = location.lat;
1199
+ payload.lng = location.lng;
1200
+ // Reverse geocode if address is missing and Nominatim is configured
1201
+ if (!location.address && config.nominatimUrl) {
1202
+ const geocoded = await reverseGeocode(location.lat, location.lng, config.nominatimUrl);
1203
+ if (geocoded) {
1204
+ payload.address = geocoded.address;
1205
+ if (!location.place_label && geocoded.placeLabel) {
1206
+ payload.place_label = geocoded.placeLabel;
1207
+ }
1208
+ }
1209
+ }
1210
+ if (location.address)
1211
+ payload.address = location.address;
1212
+ if (location.place_label)
1213
+ payload.place_label = location.place_label;
1214
+ }
934
1215
  const response = await apiClient.post('/api/memories/unified', payload, { userId });
935
1216
  if (!response.success) {
936
1217
  return { success: false, error: response.error.message };
@@ -1012,6 +1293,7 @@ function createToolHandlers(state) {
1012
1293
  const queryParams = new URLSearchParams({ item_type: 'project', limit: String(limit) });
1013
1294
  if (status !== 'all')
1014
1295
  queryParams.set('status', status);
1296
+ queryParams.set('user_email', userId); // Issue #1172: scope by user
1015
1297
  const response = await apiClient.get(`/api/work-items?${queryParams}`, { userId });
1016
1298
  if (!response.success) {
1017
1299
  return { success: false, error: response.error.message };
@@ -1031,7 +1313,7 @@ function createToolHandlers(state) {
1031
1313
  async project_get(params) {
1032
1314
  const { projectId } = params;
1033
1315
  try {
1034
- const response = await apiClient.get(`/api/work-items/${projectId}`, { userId });
1316
+ const response = await apiClient.get(`/api/work-items/${projectId}?user_email=${encodeURIComponent(userId)}`, { userId });
1035
1317
  if (!response.success) {
1036
1318
  return { success: false, error: response.error.message };
1037
1319
  }
@@ -1052,7 +1334,7 @@ function createToolHandlers(state) {
1052
1334
  async project_create(params) {
1053
1335
  const { name, description, status = 'active', } = params;
1054
1336
  try {
1055
- const response = await apiClient.post('/api/work-items', { title: name, description, item_type: 'project', status }, { userId });
1337
+ const response = await apiClient.post('/api/work-items', { title: name, description, item_type: 'project', status, user_email: userId }, { userId });
1056
1338
  if (!response.success) {
1057
1339
  return { success: false, error: response.error.message };
1058
1340
  }
@@ -1070,22 +1352,41 @@ function createToolHandlers(state) {
1070
1352
  }
1071
1353
  },
1072
1354
  async todo_list(params) {
1073
- const { projectId, status = 'pending', limit = 20, } = params;
1355
+ const { projectId, completed, limit = 50, offset = 0, } = params;
1074
1356
  try {
1075
- const queryParams = new URLSearchParams({ item_type: 'task', limit: String(limit) });
1076
- if (status !== 'all')
1077
- queryParams.set('status', status);
1357
+ const queryParams = new URLSearchParams({
1358
+ item_type: 'task',
1359
+ limit: String(limit),
1360
+ offset: String(offset),
1361
+ user_email: userId, // Issue #1172: scope by user
1362
+ });
1078
1363
  if (projectId)
1079
1364
  queryParams.set('parent_work_item_id', projectId);
1365
+ if (completed !== undefined) {
1366
+ queryParams.set('status', completed ? 'completed' : 'active');
1367
+ }
1080
1368
  const response = await apiClient.get(`/api/work-items?${queryParams}`, { userId });
1081
1369
  if (!response.success) {
1082
1370
  return { success: false, error: response.error.message };
1083
1371
  }
1084
1372
  const todos = response.data.items ?? [];
1085
- const content = todos.length > 0 ? todos.map((t) => `- [${t.status}] ${t.title}`).join('\n') : 'No todos found.';
1373
+ const total = response.data.total ?? todos.length;
1374
+ if (todos.length === 0) {
1375
+ return {
1376
+ success: true,
1377
+ data: { content: 'No todos found.', details: { count: 0, total: 0, todos: [] } },
1378
+ };
1379
+ }
1380
+ const content = todos
1381
+ .map((t) => {
1382
+ const checkbox = t.completed ? '[x]' : '[ ]';
1383
+ const dueStr = t.dueDate ? ` (due: ${t.dueDate})` : '';
1384
+ return `- ${checkbox} ${t.title}${dueStr}`;
1385
+ })
1386
+ .join('\n');
1086
1387
  return {
1087
1388
  success: true,
1088
- data: { content, details: { count: todos.length, todos } },
1389
+ data: { content, details: { count: todos.length, total, todos } },
1089
1390
  };
1090
1391
  }
1091
1392
  catch (error) {
@@ -1096,7 +1397,7 @@ function createToolHandlers(state) {
1096
1397
  async todo_create(params) {
1097
1398
  const { title, description, projectId, priority = 'medium', dueDate, } = params;
1098
1399
  try {
1099
- const body = { title, description, item_type: 'task', priority };
1400
+ const body = { title, description, item_type: 'task', priority, user_email: userId };
1100
1401
  if (projectId)
1101
1402
  body.parent_work_item_id = projectId;
1102
1403
  if (dueDate)
@@ -1121,7 +1422,7 @@ function createToolHandlers(state) {
1121
1422
  async todo_complete(params) {
1122
1423
  const { todoId } = params;
1123
1424
  try {
1124
- const response = await apiClient.patch(`/api/work-items/${todoId}/status`, { status: 'completed' }, { userId });
1425
+ const response = await apiClient.patch(`/api/work-items/${todoId}/status?user_email=${encodeURIComponent(userId)}`, { status: 'completed' }, { userId });
1125
1426
  if (!response.success) {
1126
1427
  return { success: false, error: response.error.message };
1127
1428
  }
@@ -1135,10 +1436,87 @@ function createToolHandlers(state) {
1135
1436
  return { success: false, error: 'Failed to complete todo' };
1136
1437
  }
1137
1438
  },
1439
+ async todo_search(params) {
1440
+ const { query, limit = 10, kind, status, } = params;
1441
+ if (!query || query.trim().length === 0) {
1442
+ return { success: false, error: 'query is required' };
1443
+ }
1444
+ try {
1445
+ // Over-fetch by 3x to compensate for client-side kind/status filtering (Issue #1216 review fix)
1446
+ const fetchLimit = (kind || status) ? Math.min(limit * 3, 50) : limit;
1447
+ const queryParams = new URLSearchParams({
1448
+ q: query.trim(),
1449
+ types: 'work_item',
1450
+ limit: String(fetchLimit),
1451
+ semantic: 'true',
1452
+ user_email: userId, // Issue #1216: scope results to current user
1453
+ });
1454
+ const response = await apiClient.get(`/api/search?${queryParams}`, { userId });
1455
+ if (!response.success) {
1456
+ return { success: false, error: response.error.message };
1457
+ }
1458
+ let results = response.data.results ?? [];
1459
+ // Client-side filtering by kind and status, then truncate to requested limit
1460
+ if (kind) {
1461
+ results = results.filter((r) => r.metadata?.kind === kind);
1462
+ }
1463
+ if (status) {
1464
+ results = results.filter((r) => r.metadata?.status === status);
1465
+ }
1466
+ results = results.slice(0, limit);
1467
+ if (results.length === 0) {
1468
+ return {
1469
+ success: true,
1470
+ data: {
1471
+ content: 'No matching work items found.',
1472
+ details: { count: 0, results: [], searchType: response.data.search_type },
1473
+ },
1474
+ };
1475
+ }
1476
+ const content = results
1477
+ .map((r) => {
1478
+ const kindStr = r.metadata?.kind ? `[${r.metadata.kind}]` : '';
1479
+ const statusStr = r.metadata?.status ? ` (${r.metadata.status})` : '';
1480
+ const snippetStr = r.snippet ? ` - ${r.snippet}` : '';
1481
+ return `- ${kindStr} **${r.title}**${statusStr}${snippetStr}`;
1482
+ })
1483
+ .join('\n');
1484
+ return {
1485
+ success: true,
1486
+ data: {
1487
+ content,
1488
+ details: {
1489
+ count: results.length,
1490
+ results: results.map((r) => ({
1491
+ id: r.id,
1492
+ title: r.title,
1493
+ snippet: r.snippet,
1494
+ score: r.score,
1495
+ kind: r.metadata?.kind,
1496
+ status: r.metadata?.status,
1497
+ })),
1498
+ searchType: response.data.search_type,
1499
+ },
1500
+ },
1501
+ };
1502
+ }
1503
+ catch (error) {
1504
+ logger.error('todo_search failed', { error });
1505
+ return { success: false, error: 'Failed to search work items' };
1506
+ }
1507
+ },
1508
+ async project_search(params) {
1509
+ const tool = createProjectSearchTool({ client: apiClient, logger, config, userId });
1510
+ return tool.execute(params);
1511
+ },
1512
+ async context_search(params) {
1513
+ const tool = createContextSearchTool({ client: apiClient, logger, config, userId });
1514
+ return tool.execute(params);
1515
+ },
1138
1516
  async contact_search(params) {
1139
1517
  const { query, limit = 10 } = params;
1140
1518
  try {
1141
- const queryParams = new URLSearchParams({ search: query, limit: String(limit) });
1519
+ const queryParams = new URLSearchParams({ search: query, limit: String(limit), user_email: userId });
1142
1520
  const response = await apiClient.get(`/api/contacts?${queryParams}`, {
1143
1521
  userId,
1144
1522
  });
@@ -1160,9 +1538,7 @@ function createToolHandlers(state) {
1160
1538
  async contact_get(params) {
1161
1539
  const { contactId } = params;
1162
1540
  try {
1163
- const response = await apiClient.get(`/api/contacts/${contactId}`, {
1164
- userId,
1165
- });
1541
+ const response = await apiClient.get(`/api/contacts/${contactId}?user_email=${encodeURIComponent(userId)}`, { userId });
1166
1542
  if (!response.success) {
1167
1543
  return { success: false, error: response.error.message };
1168
1544
  }
@@ -1188,7 +1564,7 @@ function createToolHandlers(state) {
1188
1564
  const { name, notes } = params;
1189
1565
  try {
1190
1566
  // API requires displayName, not name. Email/phone are stored as separate contact_endpoint records.
1191
- const response = await apiClient.post('/api/contacts', { displayName: name, notes }, { userId });
1567
+ const response = await apiClient.post('/api/contacts', { displayName: name, notes, user_email: userId }, { userId });
1192
1568
  if (!response.success) {
1193
1569
  return { success: false, error: response.error.message };
1194
1570
  }
@@ -1285,13 +1661,6 @@ function createToolHandlers(state) {
1285
1661
  },
1286
1662
  async email_send(params) {
1287
1663
  const { to, subject, body, htmlBody, threadId, idempotencyKey } = params;
1288
- // Check Postmark configuration
1289
- if (!config.postmarkToken || !config.postmarkFromEmail) {
1290
- return {
1291
- success: false,
1292
- error: 'Postmark is not configured. Please configure Postmark credentials.',
1293
- };
1294
- }
1295
1664
  // Validate email format
1296
1665
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
1297
1666
  if (!emailRegex.test(to)) {
@@ -1329,7 +1698,7 @@ function createToolHandlers(state) {
1329
1698
  hasIdempotencyKey: !!idempotencyKey,
1330
1699
  });
1331
1700
  try {
1332
- const response = await apiClient.post('/api/email/messages/send', { to, subject, body, htmlBody, threadId, idempotencyKey }, { userId });
1701
+ const response = await apiClient.post('/api/postmark/email/send', { to, subject, body, htmlBody, threadId, idempotencyKey }, { userId });
1333
1702
  if (!response.success) {
1334
1703
  logger.error('email_send API error', {
1335
1704
  userId,
@@ -1432,15 +1801,35 @@ function createToolHandlers(state) {
1432
1801
  resultCount: messages.length,
1433
1802
  total,
1434
1803
  });
1435
- // Format content for display
1804
+ // Detect and log potential injection patterns in inbound messages
1805
+ for (const m of messages) {
1806
+ if (m.direction === 'inbound' && m.body) {
1807
+ const detection = detectInjectionPatterns(m.body);
1808
+ if (detection.detected) {
1809
+ logger.warn('potential prompt injection detected in message_search result', {
1810
+ userId,
1811
+ messageId: m.id,
1812
+ patterns: detection.patterns,
1813
+ });
1814
+ }
1815
+ }
1816
+ }
1817
+ // Format content for display with injection protection
1436
1818
  const content = messages.length > 0
1437
1819
  ? messages
1438
1820
  .map((m) => {
1439
1821
  const prefix = m.direction === 'inbound' ? '←' : '→';
1440
- const contact = m.contactName || 'Unknown';
1822
+ const contact = sanitizeMetadataField(m.contactName || 'Unknown');
1823
+ const safeChannel = sanitizeMetadataField(m.channel);
1441
1824
  const similarity = `(${Math.round(m.similarity * 100)}%)`;
1442
- const bodyText = m.body || '';
1443
- return `${prefix} [${m.channel}] ${contact} ${similarity}: ${bodyText.substring(0, 100)}${bodyText.length > 100 ? '...' : ''}`;
1825
+ const rawBody = m.body || '';
1826
+ const truncatedBody = rawBody.substring(0, 100) + (rawBody.length > 100 ? '...' : '');
1827
+ const bodyText = sanitizeMessageForContext(truncatedBody, {
1828
+ direction: m.direction,
1829
+ channel: m.channel,
1830
+ sender: m.contactName || 'Unknown',
1831
+ });
1832
+ return `${prefix} [${safeChannel}] ${contact} ${similarity}: ${bodyText}`;
1444
1833
  })
1445
1834
  .join('\n')
1446
1835
  : 'No messages found matching your query.';
@@ -1502,17 +1891,22 @@ function createToolHandlers(state) {
1502
1891
  threadCount: results.length,
1503
1892
  total,
1504
1893
  });
1894
+ // Format content with injection protection.
1895
+ // Sanitize all fields that may contain external message content.
1505
1896
  const content = results.length > 0
1506
1897
  ? results
1507
1898
  .map((r) => {
1508
1899
  // Handle both thread and search result formats
1509
1900
  if ('channel' in r) {
1510
1901
  const t = r;
1511
- const contact = t.contactName || t.endpointValue || 'Unknown';
1902
+ const safeContact = sanitizeMetadataField(t.contactName || t.endpointValue || 'Unknown');
1903
+ const safeChannel = sanitizeMetadataField(t.channel);
1512
1904
  const msgCount = t.messageCount ? `${t.messageCount} message${t.messageCount !== 1 ? 's' : ''}` : '';
1513
- return `[${t.channel}] ${contact}${msgCount ? ` - ${msgCount}` : ''}`;
1905
+ return `[${safeChannel}] ${safeContact}${msgCount ? ` - ${msgCount}` : ''}`;
1514
1906
  }
1515
- return `- ${r.title || r.snippet || r.id}`;
1907
+ const safeTitle = r.title ? sanitizeMetadataField(r.title) : '';
1908
+ const wrappedSnippet = r.snippet ? wrapExternalMessage(r.snippet) : '';
1909
+ return `- ${safeTitle || wrappedSnippet || r.id}`;
1516
1910
  })
1517
1911
  .join('\n')
1518
1912
  : 'No threads found.';
@@ -1571,14 +1965,34 @@ function createToolHandlers(state) {
1571
1965
  threadId,
1572
1966
  messageCount: messages.length,
1573
1967
  });
1574
- const contact = thread.contactName || thread.endpointValue || 'Unknown';
1575
- const header = `Thread with ${contact} [${thread.channel}]`;
1968
+ const contact = sanitizeMetadataField(thread.contactName || thread.endpointValue || 'Unknown');
1969
+ const safeChannel = sanitizeMetadataField(thread.channel);
1970
+ const header = `Thread with ${contact} [${safeChannel}]`;
1971
+ // Detect and log potential injection patterns in inbound messages
1972
+ for (const m of messages) {
1973
+ if (m.direction === 'inbound' && m.body) {
1974
+ const detection = detectInjectionPatterns(m.body);
1975
+ if (detection.detected) {
1976
+ logger.warn('potential prompt injection detected in thread_get result', {
1977
+ userId,
1978
+ threadId,
1979
+ messageId: m.id,
1980
+ patterns: detection.patterns,
1981
+ });
1982
+ }
1983
+ }
1984
+ }
1576
1985
  const messageContent = messages.length > 0
1577
1986
  ? messages
1578
1987
  .map((m) => {
1579
1988
  const prefix = m.direction === 'inbound' ? '←' : '→';
1580
1989
  const timestamp = new Date(m.createdAt).toLocaleString();
1581
- return `${prefix} [${timestamp}] ${m.body}`;
1990
+ const body = sanitizeMessageForContext(m.body || '', {
1991
+ direction: m.direction,
1992
+ channel: thread.channel,
1993
+ sender: contact,
1994
+ });
1995
+ return `${prefix} [${timestamp}] ${body}`;
1582
1996
  })
1583
1997
  .join('\n')
1584
1998
  : 'No messages in this thread.';
@@ -1623,6 +2037,7 @@ function createToolHandlers(state) {
1623
2037
  contact_a,
1624
2038
  contact_b,
1625
2039
  relationship_type: relationship,
2040
+ user_email: userId, // Issue #1172: scope by user
1626
2041
  };
1627
2042
  if (notes) {
1628
2043
  body.notes = notes;
@@ -1669,15 +2084,40 @@ function createToolHandlers(state) {
1669
2084
  hasTypeFilter: !!type_filter,
1670
2085
  });
1671
2086
  try {
1672
- const queryParams = new URLSearchParams({ contact_id: contact });
1673
- if (type_filter) {
1674
- queryParams.set('relationship_type_id', type_filter);
2087
+ // Resolve contact to a UUID accept UUID directly or search by name
2088
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2089
+ let contactId;
2090
+ if (uuidRegex.test(contact)) {
2091
+ contactId = contact;
1675
2092
  }
1676
- const response = await apiClient.get(`/api/relationships?${queryParams}`, { userId });
2093
+ else {
2094
+ // Search for contact by name (Issue #1172: scope by user_email)
2095
+ const searchParams = new URLSearchParams({ search: contact, limit: '1', user_email: userId });
2096
+ const searchResponse = await apiClient.get(`/api/contacts?${searchParams}`, { userId });
2097
+ if (!searchResponse.success) {
2098
+ return { success: false, error: searchResponse.error.message };
2099
+ }
2100
+ const contacts = searchResponse.data.contacts ?? [];
2101
+ if (contacts.length === 0) {
2102
+ return { success: false, error: 'Contact not found.' };
2103
+ }
2104
+ contactId = contacts[0].id;
2105
+ }
2106
+ // Use graph traversal endpoint which returns relatedContacts
2107
+ const response = await apiClient.get(`/api/contacts/${contactId}/relationships?user_email=${encodeURIComponent(userId)}`, { userId });
1677
2108
  if (!response.success) {
2109
+ if (response.error.code === 'NOT_FOUND') {
2110
+ return { success: false, error: 'Contact not found.' };
2111
+ }
1678
2112
  return { success: false, error: response.error.message };
1679
2113
  }
1680
- const { contactId, contactName, relatedContacts } = response.data;
2114
+ let { relatedContacts } = response.data;
2115
+ const { contactName } = response.data;
2116
+ // Apply type_filter client-side if provided
2117
+ if (type_filter && relatedContacts.length > 0) {
2118
+ const filterLower = type_filter.toLowerCase();
2119
+ relatedContacts = relatedContacts.filter((rel) => rel.relationshipTypeName.toLowerCase().includes(filterLower) || rel.relationshipTypeLabel.toLowerCase().includes(filterLower));
2120
+ }
1681
2121
  if (relatedContacts.length === 0) {
1682
2122
  return {
1683
2123
  success: true,
@@ -1825,6 +2265,18 @@ function createToolHandlers(state) {
1825
2265
  skill_store_aggregate: (params) => aggregateTool.execute(params),
1826
2266
  };
1827
2267
  })(),
2268
+ // Entity link tools: delegate to tool modules (Issue #1220)
2269
+ ...(() => {
2270
+ const toolOptions = { client: apiClient, logger, config, userId };
2271
+ const setTool = createLinksSetTool(toolOptions);
2272
+ const queryTool = createLinksQueryTool(toolOptions);
2273
+ const removeTool = createLinksRemoveTool(toolOptions);
2274
+ return {
2275
+ links_set: (params) => setTool.execute(params),
2276
+ links_query: (params) => queryTool.execute(params),
2277
+ links_remove: (params) => removeTool.execute(params),
2278
+ };
2279
+ })(),
1828
2280
  };
1829
2281
  }
1830
2282
  /**
@@ -1876,7 +2328,7 @@ export const registerOpenClaw = (api) => {
1876
2328
  const state = { config, logger, apiClient, userId };
1877
2329
  // Create tool handlers
1878
2330
  const handlers = createToolHandlers(state);
1879
- // Register all 27 tools with correct OpenClaw Gateway execute signature
2331
+ // Register all 30 tools with correct OpenClaw Gateway execute signature
1880
2332
  // Signature: (toolCallId: string, params: T, signal?: AbortSignal, onUpdate?: (partial: any) => void) => AgentToolResult
1881
2333
  const tools = [
1882
2334
  {
@@ -1960,6 +2412,33 @@ export const registerOpenClaw = (api) => {
1960
2412
  return toAgentToolResult(result);
1961
2413
  },
1962
2414
  },
2415
+ {
2416
+ name: 'todo_search',
2417
+ description: 'Search todos and work items by natural language query. Uses semantic and text search to find relevant items. Optionally filter by kind or status.',
2418
+ parameters: todoSearchSchema,
2419
+ execute: async (_toolCallId, params, _signal, _onUpdate) => {
2420
+ const result = await handlers.todo_search(params);
2421
+ return toAgentToolResult(result);
2422
+ },
2423
+ },
2424
+ {
2425
+ name: 'project_search',
2426
+ description: 'Search projects by natural language query. Uses semantic and text search to find relevant projects. Optionally filter by status (active, completed, archived).',
2427
+ parameters: projectSearchSchema,
2428
+ execute: async (_toolCallId, params, _signal, _onUpdate) => {
2429
+ const result = await handlers.project_search(params);
2430
+ return toAgentToolResult(result);
2431
+ },
2432
+ },
2433
+ {
2434
+ name: 'context_search',
2435
+ description: 'Search across memories, todos, projects, and messages simultaneously. Use when you need broad context about a topic, person, or project. Returns a blended ranked list from all entity types. Optionally filter by entity_types to narrow the search.',
2436
+ parameters: contextSearchSchema,
2437
+ execute: async (_toolCallId, params, _signal, _onUpdate) => {
2438
+ const result = await handlers.context_search(params);
2439
+ return toAgentToolResult(result);
2440
+ },
2441
+ },
1963
2442
  {
1964
2443
  name: 'contact_search',
1965
2444
  description: 'Search contacts by name, email, or other fields. Use to find people.',
@@ -2122,6 +2601,33 @@ export const registerOpenClaw = (api) => {
2122
2601
  return toAgentToolResult(result);
2123
2602
  },
2124
2603
  },
2604
+ {
2605
+ name: 'links_set',
2606
+ description: 'Create a link between two entities (memory, todo, project, contact, GitHub issue, or URL). Links are bidirectional and can be traversed from either end. Use to connect related items for cross-reference and context discovery.',
2607
+ parameters: linksSetSchema,
2608
+ execute: async (_toolCallId, params, _signal, _onUpdate) => {
2609
+ const result = await handlers.links_set(params);
2610
+ return toAgentToolResult(result);
2611
+ },
2612
+ },
2613
+ {
2614
+ name: 'links_query',
2615
+ description: 'Query all links for an entity (memory, todo, project, or contact). Returns connected entities including other items, GitHub issues, and URLs. Optionally filter by link target types.',
2616
+ parameters: linksQuerySchema,
2617
+ execute: async (_toolCallId, params, _signal, _onUpdate) => {
2618
+ const result = await handlers.links_query(params);
2619
+ return toAgentToolResult(result);
2620
+ },
2621
+ },
2622
+ {
2623
+ name: 'links_remove',
2624
+ description: 'Remove a link between two entities. Deletes both directions of the link. Use when a connection is no longer relevant or was created in error.',
2625
+ parameters: linksRemoveSchema,
2626
+ execute: async (_toolCallId, params, _signal, _onUpdate) => {
2627
+ const result = await handlers.links_remove(params);
2628
+ return toAgentToolResult(result);
2629
+ },
2630
+ },
2125
2631
  ];
2126
2632
  for (const tool of tools) {
2127
2633
  api.registerTool(tool);
@@ -2228,6 +2734,54 @@ export const registerOpenClaw = (api) => {
2228
2734
  api.registerHook('agentEnd', agentEndHandler);
2229
2735
  }
2230
2736
  }
2737
+ // Register auto-linking hook for inbound messages (Issue #1223)
2738
+ // When an inbound SMS/email arrives, automatically link the thread to
2739
+ // matching contacts (by sender email/phone) and related projects/todos
2740
+ // (by semantic content matching).
2741
+ {
2742
+ /**
2743
+ * message_received handler: Extracts sender and content info from the
2744
+ * inbound message event and runs auto-linking in the background.
2745
+ * Failures are logged but never crash message processing.
2746
+ */
2747
+ const messageReceivedHandler = async (event, _ctx) => {
2748
+ // Skip if no thread ID (nothing to link to)
2749
+ if (!event.threadId) {
2750
+ logger.debug('Auto-link skipped: no threadId in message_received event');
2751
+ return;
2752
+ }
2753
+ // Skip if no content and no sender info (nothing to match on)
2754
+ if (!event.content && !event.senderEmail && !event.senderPhone && !event.sender) {
2755
+ logger.debug('Auto-link skipped: no content or sender info in event');
2756
+ return;
2757
+ }
2758
+ try {
2759
+ await autoLinkInboundMessage({
2760
+ client: apiClient,
2761
+ logger,
2762
+ userId,
2763
+ message: {
2764
+ threadId: event.threadId,
2765
+ senderEmail: event.senderEmail ?? (event.sender?.includes('@') ? event.sender : undefined),
2766
+ senderPhone: event.senderPhone ?? (event.sender && !event.sender.includes('@') ? event.sender : undefined),
2767
+ content: event.content ?? '',
2768
+ },
2769
+ });
2770
+ }
2771
+ catch (error) {
2772
+ // Hook errors should never crash inbound message processing
2773
+ logger.error('Auto-link hook failed', {
2774
+ error: error instanceof Error ? error.message : String(error),
2775
+ });
2776
+ }
2777
+ };
2778
+ if (typeof api.on === 'function') {
2779
+ api.on('message_received', messageReceivedHandler);
2780
+ }
2781
+ else {
2782
+ api.registerHook('messageReceived', messageReceivedHandler);
2783
+ }
2784
+ }
2231
2785
  // Register Gateway RPC methods (Issue #324)
2232
2786
  const gatewayMethods = createGatewayMethods({
2233
2787
  logger,
@@ -2327,6 +2881,8 @@ export const schemas = {
2327
2881
  todoList: todoListSchema,
2328
2882
  todoCreate: todoCreateSchema,
2329
2883
  todoComplete: todoCompleteSchema,
2884
+ todoSearch: todoSearchSchema,
2885
+ projectSearch: projectSearchSchema,
2330
2886
  contactSearch: contactSearchSchema,
2331
2887
  contactGet: contactGetSchema,
2332
2888
  contactCreate: contactCreateSchema,