@troykelly/openclaw-projects 0.0.11 → 0.0.13

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 (97) hide show
  1. package/dist/config.d.ts +16 -0
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js +11 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/hooks.d.ts.map +1 -1
  6. package/dist/hooks.js +22 -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 +640 -55
  11. package/dist/register-openclaw.js.map +1 -1
  12. package/dist/tools/context-search.d.ts +79 -0
  13. package/dist/tools/context-search.d.ts.map +1 -0
  14. package/dist/tools/context-search.js +267 -0
  15. package/dist/tools/context-search.js.map +1 -0
  16. package/dist/tools/email-send.d.ts.map +1 -1
  17. package/dist/tools/email-send.js +1 -14
  18. package/dist/tools/email-send.js.map +1 -1
  19. package/dist/tools/entity-links.d.ts +117 -0
  20. package/dist/tools/entity-links.d.ts.map +1 -0
  21. package/dist/tools/entity-links.js +446 -0
  22. package/dist/tools/entity-links.js.map +1 -0
  23. package/dist/tools/index.d.ts +4 -0
  24. package/dist/tools/index.d.ts.map +1 -1
  25. package/dist/tools/index.js +8 -0
  26. package/dist/tools/index.js.map +1 -1
  27. package/dist/tools/memory-recall.d.ts +28 -0
  28. package/dist/tools/memory-recall.d.ts.map +1 -1
  29. package/dist/tools/memory-recall.js +42 -3
  30. package/dist/tools/memory-recall.js.map +1 -1
  31. package/dist/tools/memory-store.d.ts +57 -0
  32. package/dist/tools/memory-store.d.ts.map +1 -1
  33. package/dist/tools/memory-store.js +29 -2
  34. package/dist/tools/memory-store.js.map +1 -1
  35. package/dist/tools/message-search.d.ts +2 -2
  36. package/dist/tools/message-search.d.ts.map +1 -1
  37. package/dist/tools/message-search.js +36 -3
  38. package/dist/tools/message-search.js.map +1 -1
  39. package/dist/tools/notes.d.ts +2 -2
  40. package/dist/tools/project-search.d.ts +92 -0
  41. package/dist/tools/project-search.d.ts.map +1 -0
  42. package/dist/tools/project-search.js +160 -0
  43. package/dist/tools/project-search.js.map +1 -0
  44. package/dist/tools/skill-store.d.ts +6 -6
  45. package/dist/tools/threads.d.ts +3 -3
  46. package/dist/tools/threads.d.ts.map +1 -1
  47. package/dist/tools/threads.js +45 -7
  48. package/dist/tools/threads.js.map +1 -1
  49. package/dist/tools/todo-search.d.ts +95 -0
  50. package/dist/tools/todo-search.d.ts.map +1 -0
  51. package/dist/tools/todo-search.js +164 -0
  52. package/dist/tools/todo-search.js.map +1 -0
  53. package/dist/types/openclaw-api.d.ts +15 -0
  54. package/dist/types/openclaw-api.d.ts.map +1 -1
  55. package/dist/utils/auto-linker.d.ts +66 -0
  56. package/dist/utils/auto-linker.d.ts.map +1 -0
  57. package/dist/utils/auto-linker.js +354 -0
  58. package/dist/utils/auto-linker.js.map +1 -0
  59. package/dist/utils/geo.d.ts +24 -0
  60. package/dist/utils/geo.d.ts.map +1 -0
  61. package/dist/utils/geo.js +38 -0
  62. package/dist/utils/geo.js.map +1 -0
  63. package/dist/utils/inbound-gate.d.ts +85 -0
  64. package/dist/utils/inbound-gate.d.ts.map +1 -0
  65. package/dist/utils/inbound-gate.js +133 -0
  66. package/dist/utils/inbound-gate.js.map +1 -0
  67. package/dist/utils/injection-log-rate-limiter.d.ts +62 -0
  68. package/dist/utils/injection-log-rate-limiter.d.ts.map +1 -0
  69. package/dist/utils/injection-log-rate-limiter.js +106 -0
  70. package/dist/utils/injection-log-rate-limiter.js.map +1 -0
  71. package/dist/utils/injection-protection.d.ts +133 -0
  72. package/dist/utils/injection-protection.d.ts.map +1 -0
  73. package/dist/utils/injection-protection.js +252 -0
  74. package/dist/utils/injection-protection.js.map +1 -0
  75. package/dist/utils/nominatim.d.ts +18 -0
  76. package/dist/utils/nominatim.d.ts.map +1 -0
  77. package/dist/utils/nominatim.js +56 -0
  78. package/dist/utils/nominatim.js.map +1 -0
  79. package/dist/utils/prompt-guard-client.d.ts +59 -0
  80. package/dist/utils/prompt-guard-client.d.ts.map +1 -0
  81. package/dist/utils/prompt-guard-client.js +99 -0
  82. package/dist/utils/prompt-guard-client.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 +25 -3
  96. package/package.json +12 -11
  97. package/LICENSE +0 -21
@@ -8,15 +8,20 @@
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 { createNotificationService } from './services/notification-service.js';
19
+ import { createContextSearchTool, createLinksQueryTool, createLinksRemoveTool, createLinksSetTool, createProjectSearchTool, createSkillStoreAggregateTool, createSkillStoreCollectionsTool, createSkillStoreDeleteTool, createSkillStoreGetTool, createSkillStoreListTool, createSkillStorePutTool, createSkillStoreSearchTool, } from './tools/index.js';
20
+ import { autoLinkInboundMessage } from './utils/auto-linker.js';
21
+ import { blendScores, computeGeoScore, haversineDistanceKm } from './utils/geo.js';
22
+ import { createBoundaryMarkers, detectInjectionPatternsAsync, sanitizeMessageForContext, sanitizeMetadataField, wrapExternalMessage, } from './utils/injection-protection.js';
23
+ import { injectionLogLimiter } from './utils/injection-log-rate-limiter.js';
24
+ import { reverseGeocode } from './utils/nominatim.js';
20
25
  /**
21
26
  * Convert internal ToolResult format to AgentToolResult format expected by OpenClaw Gateway.
22
27
  *
@@ -73,6 +78,28 @@ const memoryRecallSchema = {
73
78
  description: 'Scope search to a specific relationship between contacts',
74
79
  format: 'uuid',
75
80
  },
81
+ location: {
82
+ type: 'object',
83
+ description: 'Current location for geo-aware recall ranking',
84
+ properties: {
85
+ lat: { type: 'number', minimum: -90, maximum: 90 },
86
+ lng: { type: 'number', minimum: -180, maximum: 180 },
87
+ },
88
+ required: ['lat', 'lng'],
89
+ },
90
+ location_radius_km: {
91
+ type: 'number',
92
+ description: 'Filter memories within this radius (km) of the given location',
93
+ minimum: 0.1,
94
+ maximum: 100,
95
+ },
96
+ location_weight: {
97
+ type: 'number',
98
+ description: 'Weight for geo scoring (0 = content only, 1 = geo only)',
99
+ minimum: 0,
100
+ maximum: 1,
101
+ default: 0.3,
102
+ },
76
103
  },
77
104
  required: ['query'],
78
105
  };
@@ -121,6 +148,35 @@ const memoryStoreSchema = {
121
148
  description: 'Scope memory to a specific relationship between contacts',
122
149
  format: 'uuid',
123
150
  },
151
+ location: {
152
+ type: 'object',
153
+ description: 'Geographic location to associate with this memory',
154
+ properties: {
155
+ lat: {
156
+ type: 'number',
157
+ description: 'Latitude (-90 to 90)',
158
+ minimum: -90,
159
+ maximum: 90,
160
+ },
161
+ lng: {
162
+ type: 'number',
163
+ description: 'Longitude (-180 to 180)',
164
+ minimum: -180,
165
+ maximum: 180,
166
+ },
167
+ address: {
168
+ type: 'string',
169
+ description: 'Street address (max 500 chars)',
170
+ maxLength: 500,
171
+ },
172
+ place_label: {
173
+ type: 'string',
174
+ description: 'Short place name (max 200 chars)',
175
+ maxLength: 200,
176
+ },
177
+ },
178
+ required: ['lat', 'lng'],
179
+ },
124
180
  },
125
181
  required: ['text'],
126
182
  };
@@ -213,18 +269,22 @@ const todoListSchema = {
213
269
  description: 'Filter by project ID',
214
270
  format: 'uuid',
215
271
  },
216
- status: {
217
- type: 'string',
218
- description: 'Filter by todo status',
219
- enum: ['pending', 'in_progress', 'completed', 'all'],
220
- default: 'pending',
272
+ completed: {
273
+ type: 'boolean',
274
+ description: 'Filter by completion status. true = completed only, false = active only, omit = all.',
221
275
  },
222
276
  limit: {
223
277
  type: 'integer',
224
278
  description: 'Maximum number of todos to return',
225
279
  minimum: 1,
226
- maximum: 100,
227
- default: 20,
280
+ maximum: 200,
281
+ default: 50,
282
+ },
283
+ offset: {
284
+ type: 'integer',
285
+ description: 'Offset for pagination',
286
+ minimum: 0,
287
+ default: 0,
228
288
  },
229
289
  },
230
290
  };
@@ -278,6 +338,95 @@ const todoCompleteSchema = {
278
338
  },
279
339
  required: ['todoId'],
280
340
  };
341
+ /**
342
+ * Todo search tool JSON Schema (Issue #1216)
343
+ */
344
+ const todoSearchSchema = {
345
+ type: 'object',
346
+ properties: {
347
+ query: {
348
+ type: 'string',
349
+ description: 'Natural language search query for finding work items',
350
+ minLength: 1,
351
+ maxLength: 1000,
352
+ },
353
+ limit: {
354
+ type: 'integer',
355
+ description: 'Maximum number of results to return',
356
+ minimum: 1,
357
+ maximum: 50,
358
+ default: 10,
359
+ },
360
+ kind: {
361
+ type: 'string',
362
+ description: 'Filter by work item kind',
363
+ enum: ['task', 'project', 'initiative', 'epic', 'issue'],
364
+ },
365
+ status: {
366
+ type: 'string',
367
+ description: 'Filter by status (e.g., open, completed, in_progress)',
368
+ maxLength: 50,
369
+ },
370
+ },
371
+ required: ['query'],
372
+ };
373
+ /**
374
+ * Project search tool JSON Schema (Issue #1217)
375
+ */
376
+ const projectSearchSchema = {
377
+ type: 'object',
378
+ properties: {
379
+ query: {
380
+ type: 'string',
381
+ description: 'Natural language search query for finding projects',
382
+ minLength: 1,
383
+ maxLength: 1000,
384
+ },
385
+ limit: {
386
+ type: 'integer',
387
+ description: 'Maximum number of results to return',
388
+ minimum: 1,
389
+ maximum: 50,
390
+ default: 10,
391
+ },
392
+ status: {
393
+ type: 'string',
394
+ description: 'Filter by project status',
395
+ enum: ['active', 'completed', 'archived'],
396
+ },
397
+ },
398
+ required: ['query'],
399
+ };
400
+ /**
401
+ * Context search tool JSON Schema (Issue #1219)
402
+ */
403
+ const contextSearchSchema = {
404
+ type: 'object',
405
+ properties: {
406
+ query: {
407
+ type: 'string',
408
+ description: 'Natural language search query across memories, todos, projects, and messages',
409
+ minLength: 1,
410
+ maxLength: 1000,
411
+ },
412
+ entity_types: {
413
+ type: 'array',
414
+ description: 'Filter to specific entity types. Defaults to all (memory, todo, project, message).',
415
+ items: {
416
+ type: 'string',
417
+ enum: ['memory', 'todo', 'project', 'message'],
418
+ },
419
+ },
420
+ limit: {
421
+ type: 'integer',
422
+ description: 'Maximum number of results to return',
423
+ minimum: 1,
424
+ maximum: 50,
425
+ default: 10,
426
+ },
427
+ },
428
+ required: ['query'],
429
+ };
281
430
  /**
282
431
  * Contact search tool JSON Schema
283
432
  */
@@ -877,6 +1026,90 @@ const skillStoreAggregateSchema = {
877
1026
  },
878
1027
  required: ['skill_id', 'operation'],
879
1028
  };
1029
+ /**
1030
+ * Entity linking tool JSON Schemas (Issue #1220)
1031
+ */
1032
+ const linksSetSchema = {
1033
+ type: 'object',
1034
+ properties: {
1035
+ source_type: {
1036
+ type: 'string',
1037
+ description: 'Type of the source entity',
1038
+ enum: ['memory', 'todo', 'project', 'contact'],
1039
+ },
1040
+ source_id: {
1041
+ type: 'string',
1042
+ description: 'UUID of the source entity',
1043
+ format: 'uuid',
1044
+ },
1045
+ target_type: {
1046
+ type: 'string',
1047
+ description: 'Type of the target entity or external reference',
1048
+ enum: ['memory', 'todo', 'project', 'contact', 'github_issue', 'url'],
1049
+ },
1050
+ target_ref: {
1051
+ type: 'string',
1052
+ description: 'Reference to the target: UUID for internal entities, "owner/repo#N" for GitHub issues, URL for urls',
1053
+ minLength: 1,
1054
+ },
1055
+ label: {
1056
+ type: 'string',
1057
+ description: 'Optional label describing the link (e.g., "spawned from", "tracks", "related to")',
1058
+ maxLength: 100,
1059
+ },
1060
+ },
1061
+ required: ['source_type', 'source_id', 'target_type', 'target_ref'],
1062
+ };
1063
+ const linksQuerySchema = {
1064
+ type: 'object',
1065
+ properties: {
1066
+ entity_type: {
1067
+ type: 'string',
1068
+ description: 'Type of the entity to query links for',
1069
+ enum: ['memory', 'todo', 'project', 'contact'],
1070
+ },
1071
+ entity_id: {
1072
+ type: 'string',
1073
+ description: 'UUID of the entity to query links for',
1074
+ format: 'uuid',
1075
+ },
1076
+ link_types: {
1077
+ type: 'array',
1078
+ description: 'Optional filter to only return links to specific entity types',
1079
+ items: {
1080
+ type: 'string',
1081
+ enum: ['memory', 'todo', 'project', 'contact', 'github_issue', 'url'],
1082
+ },
1083
+ },
1084
+ },
1085
+ required: ['entity_type', 'entity_id'],
1086
+ };
1087
+ const linksRemoveSchema = {
1088
+ type: 'object',
1089
+ properties: {
1090
+ source_type: {
1091
+ type: 'string',
1092
+ description: 'Type of the source entity',
1093
+ enum: ['memory', 'todo', 'project', 'contact'],
1094
+ },
1095
+ source_id: {
1096
+ type: 'string',
1097
+ description: 'UUID of the source entity',
1098
+ format: 'uuid',
1099
+ },
1100
+ target_type: {
1101
+ type: 'string',
1102
+ description: 'Type of the target entity or external reference',
1103
+ enum: ['memory', 'todo', 'project', 'contact', 'github_issue', 'url'],
1104
+ },
1105
+ target_ref: {
1106
+ type: 'string',
1107
+ description: 'Reference to the target',
1108
+ minLength: 1,
1109
+ },
1110
+ },
1111
+ required: ['source_type', 'source_id', 'target_type', 'target_ref'],
1112
+ };
880
1113
  /**
881
1114
  * Create tool execution handlers
882
1115
  */
@@ -884,9 +1117,11 @@ function createToolHandlers(state) {
884
1117
  const { config, logger, apiClient, userId } = state;
885
1118
  return {
886
1119
  async memory_recall(params) {
887
- const { query, limit = config.maxRecallMemories, category, tags, relationship_id, } = params;
1120
+ const { query, limit = config.maxRecallMemories, category, tags, relationship_id, location, location_radius_km, location_weight, } = params;
888
1121
  try {
889
- const queryParams = new URLSearchParams({ q: query, limit: String(limit) });
1122
+ // Over-fetch when location is provided to allow geo re-ranking
1123
+ const apiLimit = location ? Math.min(limit * 3, 60) : limit;
1124
+ const queryParams = new URLSearchParams({ q: query, limit: String(apiLimit) });
890
1125
  if (category)
891
1126
  queryParams.set('memory_type', category);
892
1127
  if (tags && tags.length > 0)
@@ -897,7 +1132,36 @@ function createToolHandlers(state) {
897
1132
  if (!response.success) {
898
1133
  return { success: false, error: response.error.message };
899
1134
  }
900
- const memories = response.data.results ?? [];
1135
+ let memories = (response.data.results ?? []).map((m) => ({
1136
+ ...m,
1137
+ category: m.type === 'note' ? 'other' : m.type,
1138
+ score: m.similarity,
1139
+ }));
1140
+ // Apply geo re-ranking if location is provided
1141
+ if (location) {
1142
+ const { lat: qLat, lng: qLng } = location;
1143
+ const weight = location_weight ?? 0.3;
1144
+ // Filter by radius if specified
1145
+ if (location_radius_km !== undefined) {
1146
+ memories = memories.filter((m) => {
1147
+ if (m.lat == null || m.lng == null)
1148
+ return false;
1149
+ return haversineDistanceKm(qLat, qLng, m.lat, m.lng) <= location_radius_km;
1150
+ });
1151
+ }
1152
+ // Compute blended scores and re-sort
1153
+ memories = memories
1154
+ .map((m) => {
1155
+ const contentScore = m.score ?? 0;
1156
+ let geoScore = 0.5;
1157
+ if (m.lat != null && m.lng != null) {
1158
+ geoScore = computeGeoScore(haversineDistanceKm(qLat, qLng, m.lat, m.lng));
1159
+ }
1160
+ return { ...m, score: blendScores(contentScore, geoScore, weight) };
1161
+ })
1162
+ .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
1163
+ .slice(0, limit);
1164
+ }
901
1165
  const content = memories.length > 0 ? memories.map((m) => `- [${m.type}] ${m.content}`).join('\n') : 'No relevant memories found.';
902
1166
  return {
903
1167
  success: true,
@@ -914,7 +1178,7 @@ function createToolHandlers(state) {
914
1178
  },
915
1179
  async memory_store(params) {
916
1180
  // Accept 'text' (OpenClaw native) or 'content' (backwards compat)
917
- const { text, content: contentAlias, category = 'other', importance = 0.7, tags, relationship_id, } = params;
1181
+ const { text, content: contentAlias, category = 'other', importance = 0.7, tags, relationship_id, location, } = params;
918
1182
  const memoryText = text || contentAlias;
919
1183
  if (!memoryText) {
920
1184
  return { success: false, error: 'text is required' };
@@ -931,6 +1195,24 @@ function createToolHandlers(state) {
931
1195
  payload.tags = tags;
932
1196
  if (relationship_id)
933
1197
  payload.relationship_id = relationship_id;
1198
+ if (location) {
1199
+ payload.lat = location.lat;
1200
+ payload.lng = location.lng;
1201
+ // Reverse geocode if address is missing and Nominatim is configured
1202
+ if (!location.address && config.nominatimUrl) {
1203
+ const geocoded = await reverseGeocode(location.lat, location.lng, config.nominatimUrl);
1204
+ if (geocoded) {
1205
+ payload.address = geocoded.address;
1206
+ if (!location.place_label && geocoded.placeLabel) {
1207
+ payload.place_label = geocoded.placeLabel;
1208
+ }
1209
+ }
1210
+ }
1211
+ if (location.address)
1212
+ payload.address = location.address;
1213
+ if (location.place_label)
1214
+ payload.place_label = location.place_label;
1215
+ }
934
1216
  const response = await apiClient.post('/api/memories/unified', payload, { userId });
935
1217
  if (!response.success) {
936
1218
  return { success: false, error: response.error.message };
@@ -1012,6 +1294,7 @@ function createToolHandlers(state) {
1012
1294
  const queryParams = new URLSearchParams({ item_type: 'project', limit: String(limit) });
1013
1295
  if (status !== 'all')
1014
1296
  queryParams.set('status', status);
1297
+ queryParams.set('user_email', userId); // Issue #1172: scope by user
1015
1298
  const response = await apiClient.get(`/api/work-items?${queryParams}`, { userId });
1016
1299
  if (!response.success) {
1017
1300
  return { success: false, error: response.error.message };
@@ -1031,7 +1314,7 @@ function createToolHandlers(state) {
1031
1314
  async project_get(params) {
1032
1315
  const { projectId } = params;
1033
1316
  try {
1034
- const response = await apiClient.get(`/api/work-items/${projectId}`, { userId });
1317
+ const response = await apiClient.get(`/api/work-items/${projectId}?user_email=${encodeURIComponent(userId)}`, { userId });
1035
1318
  if (!response.success) {
1036
1319
  return { success: false, error: response.error.message };
1037
1320
  }
@@ -1052,7 +1335,7 @@ function createToolHandlers(state) {
1052
1335
  async project_create(params) {
1053
1336
  const { name, description, status = 'active', } = params;
1054
1337
  try {
1055
- const response = await apiClient.post('/api/work-items', { title: name, description, item_type: 'project', status }, { userId });
1338
+ const response = await apiClient.post('/api/work-items', { title: name, description, item_type: 'project', status, user_email: userId }, { userId });
1056
1339
  if (!response.success) {
1057
1340
  return { success: false, error: response.error.message };
1058
1341
  }
@@ -1070,22 +1353,41 @@ function createToolHandlers(state) {
1070
1353
  }
1071
1354
  },
1072
1355
  async todo_list(params) {
1073
- const { projectId, status = 'pending', limit = 20, } = params;
1356
+ const { projectId, completed, limit = 50, offset = 0, } = params;
1074
1357
  try {
1075
- const queryParams = new URLSearchParams({ item_type: 'task', limit: String(limit) });
1076
- if (status !== 'all')
1077
- queryParams.set('status', status);
1358
+ const queryParams = new URLSearchParams({
1359
+ item_type: 'task',
1360
+ limit: String(limit),
1361
+ offset: String(offset),
1362
+ user_email: userId, // Issue #1172: scope by user
1363
+ });
1078
1364
  if (projectId)
1079
1365
  queryParams.set('parent_work_item_id', projectId);
1366
+ if (completed !== undefined) {
1367
+ queryParams.set('status', completed ? 'completed' : 'active');
1368
+ }
1080
1369
  const response = await apiClient.get(`/api/work-items?${queryParams}`, { userId });
1081
1370
  if (!response.success) {
1082
1371
  return { success: false, error: response.error.message };
1083
1372
  }
1084
1373
  const todos = response.data.items ?? [];
1085
- const content = todos.length > 0 ? todos.map((t) => `- [${t.status}] ${t.title}`).join('\n') : 'No todos found.';
1374
+ const total = response.data.total ?? todos.length;
1375
+ if (todos.length === 0) {
1376
+ return {
1377
+ success: true,
1378
+ data: { content: 'No todos found.', details: { count: 0, total: 0, todos: [] } },
1379
+ };
1380
+ }
1381
+ const content = todos
1382
+ .map((t) => {
1383
+ const checkbox = t.completed ? '[x]' : '[ ]';
1384
+ const dueStr = t.dueDate ? ` (due: ${t.dueDate})` : '';
1385
+ return `- ${checkbox} ${t.title}${dueStr}`;
1386
+ })
1387
+ .join('\n');
1086
1388
  return {
1087
1389
  success: true,
1088
- data: { content, details: { count: todos.length, todos } },
1390
+ data: { content, details: { count: todos.length, total, todos } },
1089
1391
  };
1090
1392
  }
1091
1393
  catch (error) {
@@ -1096,7 +1398,7 @@ function createToolHandlers(state) {
1096
1398
  async todo_create(params) {
1097
1399
  const { title, description, projectId, priority = 'medium', dueDate, } = params;
1098
1400
  try {
1099
- const body = { title, description, item_type: 'task', priority };
1401
+ const body = { title, description, item_type: 'task', priority, user_email: userId };
1100
1402
  if (projectId)
1101
1403
  body.parent_work_item_id = projectId;
1102
1404
  if (dueDate)
@@ -1121,7 +1423,7 @@ function createToolHandlers(state) {
1121
1423
  async todo_complete(params) {
1122
1424
  const { todoId } = params;
1123
1425
  try {
1124
- const response = await apiClient.patch(`/api/work-items/${todoId}/status`, { status: 'completed' }, { userId });
1426
+ const response = await apiClient.patch(`/api/work-items/${todoId}/status?user_email=${encodeURIComponent(userId)}`, { status: 'completed' }, { userId });
1125
1427
  if (!response.success) {
1126
1428
  return { success: false, error: response.error.message };
1127
1429
  }
@@ -1135,10 +1437,87 @@ function createToolHandlers(state) {
1135
1437
  return { success: false, error: 'Failed to complete todo' };
1136
1438
  }
1137
1439
  },
1440
+ async todo_search(params) {
1441
+ const { query, limit = 10, kind, status, } = params;
1442
+ if (!query || query.trim().length === 0) {
1443
+ return { success: false, error: 'query is required' };
1444
+ }
1445
+ try {
1446
+ // Over-fetch by 3x to compensate for client-side kind/status filtering (Issue #1216 review fix)
1447
+ const fetchLimit = kind || status ? Math.min(limit * 3, 50) : limit;
1448
+ const queryParams = new URLSearchParams({
1449
+ q: query.trim(),
1450
+ types: 'work_item',
1451
+ limit: String(fetchLimit),
1452
+ semantic: 'true',
1453
+ user_email: userId, // Issue #1216: scope results to current user
1454
+ });
1455
+ const response = await apiClient.get(`/api/search?${queryParams}`, { userId });
1456
+ if (!response.success) {
1457
+ return { success: false, error: response.error.message };
1458
+ }
1459
+ let results = response.data.results ?? [];
1460
+ // Client-side filtering by kind and status, then truncate to requested limit
1461
+ if (kind) {
1462
+ results = results.filter((r) => r.metadata?.kind === kind);
1463
+ }
1464
+ if (status) {
1465
+ results = results.filter((r) => r.metadata?.status === status);
1466
+ }
1467
+ results = results.slice(0, limit);
1468
+ if (results.length === 0) {
1469
+ return {
1470
+ success: true,
1471
+ data: {
1472
+ content: 'No matching work items found.',
1473
+ details: { count: 0, results: [], searchType: response.data.search_type },
1474
+ },
1475
+ };
1476
+ }
1477
+ const content = results
1478
+ .map((r) => {
1479
+ const kindStr = r.metadata?.kind ? `[${r.metadata.kind}]` : '';
1480
+ const statusStr = r.metadata?.status ? ` (${r.metadata.status})` : '';
1481
+ const snippetStr = r.snippet ? ` - ${r.snippet}` : '';
1482
+ return `- ${kindStr} **${r.title}**${statusStr}${snippetStr}`;
1483
+ })
1484
+ .join('\n');
1485
+ return {
1486
+ success: true,
1487
+ data: {
1488
+ content,
1489
+ details: {
1490
+ count: results.length,
1491
+ results: results.map((r) => ({
1492
+ id: r.id,
1493
+ title: r.title,
1494
+ snippet: r.snippet,
1495
+ score: r.score,
1496
+ kind: r.metadata?.kind,
1497
+ status: r.metadata?.status,
1498
+ })),
1499
+ searchType: response.data.search_type,
1500
+ },
1501
+ },
1502
+ };
1503
+ }
1504
+ catch (error) {
1505
+ logger.error('todo_search failed', { error });
1506
+ return { success: false, error: 'Failed to search work items' };
1507
+ }
1508
+ },
1509
+ async project_search(params) {
1510
+ const tool = createProjectSearchTool({ client: apiClient, logger, config, userId });
1511
+ return tool.execute(params);
1512
+ },
1513
+ async context_search(params) {
1514
+ const tool = createContextSearchTool({ client: apiClient, logger, config, userId });
1515
+ return tool.execute(params);
1516
+ },
1138
1517
  async contact_search(params) {
1139
1518
  const { query, limit = 10 } = params;
1140
1519
  try {
1141
- const queryParams = new URLSearchParams({ search: query, limit: String(limit) });
1520
+ const queryParams = new URLSearchParams({ search: query, limit: String(limit), user_email: userId });
1142
1521
  const response = await apiClient.get(`/api/contacts?${queryParams}`, {
1143
1522
  userId,
1144
1523
  });
@@ -1160,9 +1539,7 @@ function createToolHandlers(state) {
1160
1539
  async contact_get(params) {
1161
1540
  const { contactId } = params;
1162
1541
  try {
1163
- const response = await apiClient.get(`/api/contacts/${contactId}`, {
1164
- userId,
1165
- });
1542
+ const response = await apiClient.get(`/api/contacts/${contactId}?user_email=${encodeURIComponent(userId)}`, { userId });
1166
1543
  if (!response.success) {
1167
1544
  return { success: false, error: response.error.message };
1168
1545
  }
@@ -1188,7 +1565,7 @@ function createToolHandlers(state) {
1188
1565
  const { name, notes } = params;
1189
1566
  try {
1190
1567
  // 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 });
1568
+ const response = await apiClient.post('/api/contacts', { displayName: name, notes, user_email: userId }, { userId });
1192
1569
  if (!response.success) {
1193
1570
  return { success: false, error: response.error.message };
1194
1571
  }
@@ -1285,13 +1662,6 @@ function createToolHandlers(state) {
1285
1662
  },
1286
1663
  async email_send(params) {
1287
1664
  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
1665
  // Validate email format
1296
1666
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
1297
1667
  if (!emailRegex.test(to)) {
@@ -1329,7 +1699,7 @@ function createToolHandlers(state) {
1329
1699
  hasIdempotencyKey: !!idempotencyKey,
1330
1700
  });
1331
1701
  try {
1332
- const response = await apiClient.post('/api/email/messages/send', { to, subject, body, htmlBody, threadId, idempotencyKey }, { userId });
1702
+ const response = await apiClient.post('/api/postmark/email/send', { to, subject, body, htmlBody, threadId, idempotencyKey }, { userId });
1333
1703
  if (!response.success) {
1334
1704
  logger.error('email_send API error', {
1335
1705
  userId,
@@ -1432,15 +1802,50 @@ function createToolHandlers(state) {
1432
1802
  resultCount: messages.length,
1433
1803
  total,
1434
1804
  });
1435
- // Format content for display
1805
+ // SECURITY: Run injection detection on the FULL message body BEFORE any
1806
+ // truncation. Bodies are later truncated to 100 chars for display, but
1807
+ // detection must see the complete content to catch payloads that an
1808
+ // attacker could hide beyond the truncation boundary. (Issue #1258)
1809
+ // Rate-limited to prevent log flooding from volume attacks. (#1257)
1810
+ for (const m of messages) {
1811
+ if (m.direction === 'inbound' && m.body) {
1812
+ const detection = await detectInjectionPatternsAsync(m.body, {
1813
+ promptGuardUrl: config.promptGuardUrl,
1814
+ });
1815
+ if (detection.detected) {
1816
+ const logDecision = injectionLogLimiter.shouldLog(userId);
1817
+ if (logDecision.log) {
1818
+ logger.warn(logDecision.summary ? 'injection detection log summary for previous window' : 'potential prompt injection detected in message_search result', {
1819
+ userId,
1820
+ messageId: m.id,
1821
+ patterns: detection.patterns,
1822
+ source: detection.source,
1823
+ ...(logDecision.suppressed > 0 && { suppressedCount: logDecision.suppressed }),
1824
+ });
1825
+ }
1826
+ }
1827
+ }
1828
+ }
1829
+ // Format content for display with injection protection.
1830
+ // NOTE: Truncation happens here AFTER detection above — do not reorder.
1831
+ // Generate a per-invocation nonce for boundary markers (#1255)
1832
+ const { nonce } = createBoundaryMarkers();
1436
1833
  const content = messages.length > 0
1437
1834
  ? messages
1438
1835
  .map((m) => {
1439
1836
  const prefix = m.direction === 'inbound' ? '←' : '→';
1440
- const contact = m.contactName || 'Unknown';
1837
+ const contact = sanitizeMetadataField(m.contactName || 'Unknown', nonce);
1838
+ const safeChannel = sanitizeMetadataField(m.channel, nonce);
1441
1839
  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 ? '...' : ''}`;
1840
+ const rawBody = m.body || '';
1841
+ const truncatedBody = rawBody.substring(0, 100) + (rawBody.length > 100 ? '...' : '');
1842
+ const bodyText = sanitizeMessageForContext(truncatedBody, {
1843
+ direction: m.direction,
1844
+ channel: m.channel,
1845
+ sender: m.contactName || 'Unknown',
1846
+ nonce,
1847
+ });
1848
+ return `${prefix} [${safeChannel}] ${contact} ${similarity}: ${bodyText}`;
1444
1849
  })
1445
1850
  .join('\n')
1446
1851
  : 'No messages found matching your query.';
@@ -1502,17 +1907,24 @@ function createToolHandlers(state) {
1502
1907
  threadCount: results.length,
1503
1908
  total,
1504
1909
  });
1910
+ // Format content with injection protection.
1911
+ // Sanitize all fields that may contain external message content.
1912
+ // Generate a per-invocation nonce for boundary markers (#1255)
1913
+ const { nonce: threadListNonce } = createBoundaryMarkers();
1505
1914
  const content = results.length > 0
1506
1915
  ? results
1507
1916
  .map((r) => {
1508
1917
  // Handle both thread and search result formats
1509
1918
  if ('channel' in r) {
1510
1919
  const t = r;
1511
- const contact = t.contactName || t.endpointValue || 'Unknown';
1920
+ const safeContact = sanitizeMetadataField(t.contactName || t.endpointValue || 'Unknown', threadListNonce);
1921
+ const safeChannel = sanitizeMetadataField(t.channel, threadListNonce);
1512
1922
  const msgCount = t.messageCount ? `${t.messageCount} message${t.messageCount !== 1 ? 's' : ''}` : '';
1513
- return `[${t.channel}] ${contact}${msgCount ? ` - ${msgCount}` : ''}`;
1923
+ return `[${safeChannel}] ${safeContact}${msgCount ? ` - ${msgCount}` : ''}`;
1514
1924
  }
1515
- return `- ${r.title || r.snippet || r.id}`;
1925
+ const safeTitle = r.title ? sanitizeMetadataField(r.title, threadListNonce) : '';
1926
+ const wrappedSnippet = r.snippet ? wrapExternalMessage(r.snippet, { nonce: threadListNonce }) : '';
1927
+ return `- ${safeTitle || wrappedSnippet || r.id}`;
1516
1928
  })
1517
1929
  .join('\n')
1518
1930
  : 'No threads found.';
@@ -1571,14 +1983,45 @@ function createToolHandlers(state) {
1571
1983
  threadId,
1572
1984
  messageCount: messages.length,
1573
1985
  });
1574
- const contact = thread.contactName || thread.endpointValue || 'Unknown';
1575
- const header = `Thread with ${contact} [${thread.channel}]`;
1986
+ // Generate a per-invocation nonce for boundary markers (#1255)
1987
+ const { nonce: threadGetNonce } = createBoundaryMarkers();
1988
+ const contact = sanitizeMetadataField(thread.contactName || thread.endpointValue || 'Unknown', threadGetNonce);
1989
+ const safeChannel = sanitizeMetadataField(thread.channel, threadGetNonce);
1990
+ const header = `Thread with ${contact} [${safeChannel}]`;
1991
+ // Detect and log potential injection patterns in inbound messages
1992
+ // Rate-limited to prevent log flooding from volume attacks. (#1257)
1993
+ for (const m of messages) {
1994
+ if (m.direction === 'inbound' && m.body) {
1995
+ const detection = await detectInjectionPatternsAsync(m.body, {
1996
+ promptGuardUrl: config.promptGuardUrl,
1997
+ });
1998
+ if (detection.detected) {
1999
+ const logDecision = injectionLogLimiter.shouldLog(userId);
2000
+ if (logDecision.log) {
2001
+ logger.warn(logDecision.summary ? 'injection detection log summary for previous window' : 'potential prompt injection detected in thread_get result', {
2002
+ userId,
2003
+ threadId,
2004
+ messageId: m.id,
2005
+ patterns: detection.patterns,
2006
+ source: detection.source,
2007
+ ...(logDecision.suppressed > 0 && { suppressedCount: logDecision.suppressed }),
2008
+ });
2009
+ }
2010
+ }
2011
+ }
2012
+ }
1576
2013
  const messageContent = messages.length > 0
1577
2014
  ? messages
1578
2015
  .map((m) => {
1579
2016
  const prefix = m.direction === 'inbound' ? '←' : '→';
1580
2017
  const timestamp = new Date(m.createdAt).toLocaleString();
1581
- return `${prefix} [${timestamp}] ${m.body}`;
2018
+ const body = sanitizeMessageForContext(m.body || '', {
2019
+ direction: m.direction,
2020
+ channel: thread.channel,
2021
+ sender: contact,
2022
+ nonce: threadGetNonce,
2023
+ });
2024
+ return `${prefix} [${timestamp}] ${body}`;
1582
2025
  })
1583
2026
  .join('\n')
1584
2027
  : 'No messages in this thread.';
@@ -1623,6 +2066,7 @@ function createToolHandlers(state) {
1623
2066
  contact_a,
1624
2067
  contact_b,
1625
2068
  relationship_type: relationship,
2069
+ user_email: userId, // Issue #1172: scope by user
1626
2070
  };
1627
2071
  if (notes) {
1628
2072
  body.notes = notes;
@@ -1669,15 +2113,40 @@ function createToolHandlers(state) {
1669
2113
  hasTypeFilter: !!type_filter,
1670
2114
  });
1671
2115
  try {
1672
- const queryParams = new URLSearchParams({ contact_id: contact });
1673
- if (type_filter) {
1674
- queryParams.set('relationship_type_id', type_filter);
2116
+ // Resolve contact to a UUID accept UUID directly or search by name
2117
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2118
+ let contactId;
2119
+ if (uuidRegex.test(contact)) {
2120
+ contactId = contact;
1675
2121
  }
1676
- const response = await apiClient.get(`/api/relationships?${queryParams}`, { userId });
2122
+ else {
2123
+ // Search for contact by name (Issue #1172: scope by user_email)
2124
+ const searchParams = new URLSearchParams({ search: contact, limit: '1', user_email: userId });
2125
+ const searchResponse = await apiClient.get(`/api/contacts?${searchParams}`, { userId });
2126
+ if (!searchResponse.success) {
2127
+ return { success: false, error: searchResponse.error.message };
2128
+ }
2129
+ const contacts = searchResponse.data.contacts ?? [];
2130
+ if (contacts.length === 0) {
2131
+ return { success: false, error: 'Contact not found.' };
2132
+ }
2133
+ contactId = contacts[0].id;
2134
+ }
2135
+ // Use graph traversal endpoint which returns relatedContacts
2136
+ const response = await apiClient.get(`/api/contacts/${contactId}/relationships?user_email=${encodeURIComponent(userId)}`, { userId });
1677
2137
  if (!response.success) {
2138
+ if (response.error.code === 'NOT_FOUND') {
2139
+ return { success: false, error: 'Contact not found.' };
2140
+ }
1678
2141
  return { success: false, error: response.error.message };
1679
2142
  }
1680
- const { contactId, contactName, relatedContacts } = response.data;
2143
+ let { relatedContacts } = response.data;
2144
+ const { contactName } = response.data;
2145
+ // Apply type_filter client-side if provided
2146
+ if (type_filter && relatedContacts.length > 0) {
2147
+ const filterLower = type_filter.toLowerCase();
2148
+ relatedContacts = relatedContacts.filter((rel) => rel.relationshipTypeName.toLowerCase().includes(filterLower) || rel.relationshipTypeLabel.toLowerCase().includes(filterLower));
2149
+ }
1681
2150
  if (relatedContacts.length === 0) {
1682
2151
  return {
1683
2152
  success: true,
@@ -1825,6 +2294,18 @@ function createToolHandlers(state) {
1825
2294
  skill_store_aggregate: (params) => aggregateTool.execute(params),
1826
2295
  };
1827
2296
  })(),
2297
+ // Entity link tools: delegate to tool modules (Issue #1220)
2298
+ ...(() => {
2299
+ const toolOptions = { client: apiClient, logger, config, userId };
2300
+ const setTool = createLinksSetTool(toolOptions);
2301
+ const queryTool = createLinksQueryTool(toolOptions);
2302
+ const removeTool = createLinksRemoveTool(toolOptions);
2303
+ return {
2304
+ links_set: (params) => setTool.execute(params),
2305
+ links_query: (params) => queryTool.execute(params),
2306
+ links_remove: (params) => removeTool.execute(params),
2307
+ };
2308
+ })(),
1828
2309
  };
1829
2310
  }
1830
2311
  /**
@@ -1876,7 +2357,7 @@ export const registerOpenClaw = (api) => {
1876
2357
  const state = { config, logger, apiClient, userId };
1877
2358
  // Create tool handlers
1878
2359
  const handlers = createToolHandlers(state);
1879
- // Register all 27 tools with correct OpenClaw Gateway execute signature
2360
+ // Register all 30 tools with correct OpenClaw Gateway execute signature
1880
2361
  // Signature: (toolCallId: string, params: T, signal?: AbortSignal, onUpdate?: (partial: any) => void) => AgentToolResult
1881
2362
  const tools = [
1882
2363
  {
@@ -1960,6 +2441,33 @@ export const registerOpenClaw = (api) => {
1960
2441
  return toAgentToolResult(result);
1961
2442
  },
1962
2443
  },
2444
+ {
2445
+ name: 'todo_search',
2446
+ 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.',
2447
+ parameters: todoSearchSchema,
2448
+ execute: async (_toolCallId, params, _signal, _onUpdate) => {
2449
+ const result = await handlers.todo_search(params);
2450
+ return toAgentToolResult(result);
2451
+ },
2452
+ },
2453
+ {
2454
+ name: 'project_search',
2455
+ description: 'Search projects by natural language query. Uses semantic and text search to find relevant projects. Optionally filter by status (active, completed, archived).',
2456
+ parameters: projectSearchSchema,
2457
+ execute: async (_toolCallId, params, _signal, _onUpdate) => {
2458
+ const result = await handlers.project_search(params);
2459
+ return toAgentToolResult(result);
2460
+ },
2461
+ },
2462
+ {
2463
+ name: 'context_search',
2464
+ 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.',
2465
+ parameters: contextSearchSchema,
2466
+ execute: async (_toolCallId, params, _signal, _onUpdate) => {
2467
+ const result = await handlers.context_search(params);
2468
+ return toAgentToolResult(result);
2469
+ },
2470
+ },
1963
2471
  {
1964
2472
  name: 'contact_search',
1965
2473
  description: 'Search contacts by name, email, or other fields. Use to find people.',
@@ -2122,6 +2630,33 @@ export const registerOpenClaw = (api) => {
2122
2630
  return toAgentToolResult(result);
2123
2631
  },
2124
2632
  },
2633
+ {
2634
+ name: 'links_set',
2635
+ 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.',
2636
+ parameters: linksSetSchema,
2637
+ execute: async (_toolCallId, params, _signal, _onUpdate) => {
2638
+ const result = await handlers.links_set(params);
2639
+ return toAgentToolResult(result);
2640
+ },
2641
+ },
2642
+ {
2643
+ name: 'links_query',
2644
+ 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.',
2645
+ parameters: linksQuerySchema,
2646
+ execute: async (_toolCallId, params, _signal, _onUpdate) => {
2647
+ const result = await handlers.links_query(params);
2648
+ return toAgentToolResult(result);
2649
+ },
2650
+ },
2651
+ {
2652
+ name: 'links_remove',
2653
+ 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.',
2654
+ parameters: linksRemoveSchema,
2655
+ execute: async (_toolCallId, params, _signal, _onUpdate) => {
2656
+ const result = await handlers.links_remove(params);
2657
+ return toAgentToolResult(result);
2658
+ },
2659
+ },
2125
2660
  ];
2126
2661
  for (const tool of tools) {
2127
2662
  api.registerTool(tool);
@@ -2228,6 +2763,54 @@ export const registerOpenClaw = (api) => {
2228
2763
  api.registerHook('agentEnd', agentEndHandler);
2229
2764
  }
2230
2765
  }
2766
+ // Register auto-linking hook for inbound messages (Issue #1223)
2767
+ // When an inbound SMS/email arrives, automatically link the thread to
2768
+ // matching contacts (by sender email/phone) and related projects/todos
2769
+ // (by semantic content matching).
2770
+ {
2771
+ /**
2772
+ * message_received handler: Extracts sender and content info from the
2773
+ * inbound message event and runs auto-linking in the background.
2774
+ * Failures are logged but never crash message processing.
2775
+ */
2776
+ const messageReceivedHandler = async (event, _ctx) => {
2777
+ // Skip if no thread ID (nothing to link to)
2778
+ if (!event.threadId) {
2779
+ logger.debug('Auto-link skipped: no threadId in message_received event');
2780
+ return;
2781
+ }
2782
+ // Skip if no content and no sender info (nothing to match on)
2783
+ if (!event.content && !event.senderEmail && !event.senderPhone && !event.sender) {
2784
+ logger.debug('Auto-link skipped: no content or sender info in event');
2785
+ return;
2786
+ }
2787
+ try {
2788
+ await autoLinkInboundMessage({
2789
+ client: apiClient,
2790
+ logger,
2791
+ userId,
2792
+ message: {
2793
+ threadId: event.threadId,
2794
+ senderEmail: event.senderEmail ?? (event.sender?.includes('@') ? event.sender : undefined),
2795
+ senderPhone: event.senderPhone ?? (event.sender && !event.sender.includes('@') ? event.sender : undefined),
2796
+ content: event.content ?? '',
2797
+ },
2798
+ });
2799
+ }
2800
+ catch (error) {
2801
+ // Hook errors should never crash inbound message processing
2802
+ logger.error('Auto-link hook failed', {
2803
+ error: error instanceof Error ? error.message : String(error),
2804
+ });
2805
+ }
2806
+ };
2807
+ if (typeof api.on === 'function') {
2808
+ api.on('message_received', messageReceivedHandler);
2809
+ }
2810
+ else {
2811
+ api.registerHook('messageReceived', messageReceivedHandler);
2812
+ }
2813
+ }
2231
2814
  // Register Gateway RPC methods (Issue #324)
2232
2815
  const gatewayMethods = createGatewayMethods({
2233
2816
  logger,
@@ -2327,6 +2910,8 @@ export const schemas = {
2327
2910
  todoList: todoListSchema,
2328
2911
  todoCreate: todoCreateSchema,
2329
2912
  todoComplete: todoCompleteSchema,
2913
+ todoSearch: todoSearchSchema,
2914
+ projectSearch: projectSearchSchema,
2330
2915
  contactSearch: contactSearchSchema,
2331
2916
  contactGet: contactGetSchema,
2332
2917
  contactCreate: contactCreateSchema,