@shadowforge0/aquifer-memory 0.7.0 → 0.9.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.
package/consumers/mcp.js CHANGED
@@ -2,7 +2,12 @@
2
2
  'use strict';
3
3
 
4
4
  /**
5
- * Aquifer MCP Server — session_recall tool via Model Context Protocol.
5
+ * Aquifer MCP Server — canonical external contract for agent host integration.
6
+ *
7
+ * This is the primary integration surface for Aquifer. Agent hosts (Claude Code,
8
+ * Codex, OpenCode, etc.) should integrate through this MCP server.
9
+ *
10
+ * Tools: session_recall, session_feedback, memory_stats, memory_pending
6
11
  *
7
12
  * Usage:
8
13
  * npx aquifer mcp
@@ -62,14 +67,14 @@ async function main() {
62
67
  } catch (e) {
63
68
  process.stderr.write(
64
69
  'aquifer mcp requires @modelcontextprotocol/sdk and zod.\n' +
65
- 'Install: npm install @modelcontextprotocol/sdk zod\n'
70
+ 'These should be installed automatically. Try: npm install\n'
66
71
  );
67
72
  process.exit(1);
68
73
  }
69
74
 
70
75
  const server = new McpServer({
71
76
  name: 'aquifer-memory',
72
- version: '0.6.0',
77
+ version: '0.9.0',
73
78
  });
74
79
 
75
80
  server.tool(
@@ -84,6 +89,7 @@ async function main() {
84
89
  dateTo: z.string().optional().describe('End date YYYY-MM-DD'),
85
90
  entities: z.array(z.string()).optional().describe('Entity names to match'),
86
91
  entityMode: z.enum(['any', 'all']).optional().describe('"any" (default, boost) or "all" (only sessions with every entity)'),
92
+ mode: z.enum(['fts', 'hybrid', 'vector']).optional().describe('Recall mode: "fts" (keyword only, no embed needed), "hybrid" (default, FTS + vector), "vector" (vector only)'),
87
93
  },
88
94
  async (params) => {
89
95
  try {
@@ -100,6 +106,7 @@ async function main() {
100
106
  recallOpts.entities = params.entities;
101
107
  recallOpts.entityMode = params.entityMode || 'any';
102
108
  }
109
+ if (params.mode) recallOpts.mode = params.mode;
103
110
 
104
111
  const results = await aquifer.recall(params.query, recallOpts);
105
112
  const text = formatResults(results, params.query);
@@ -120,6 +127,7 @@ async function main() {
120
127
  sessionId: z.string().min(1).describe('Session ID to give feedback on'),
121
128
  verdict: z.enum(['helpful', 'unhelpful']).describe('Was the recalled session useful?'),
122
129
  note: z.string().optional().describe('Optional reason'),
130
+ agentId: z.string().optional().describe('Agent ID the session was stored under (e.g. "main"). Defaults to "agent" if omitted.'),
123
131
  },
124
132
  async (params) => {
125
133
  try {
@@ -127,6 +135,7 @@ async function main() {
127
135
  const result = await aquifer.feedback(params.sessionId, {
128
136
  verdict: params.verdict,
129
137
  note: params.note || undefined,
138
+ agentId: params.agentId || undefined,
130
139
  });
131
140
  return {
132
141
  content: [{ type: 'text', text: `Feedback: ${result.verdict} (trust ${result.trustBefore.toFixed(2)} → ${result.trustAfter.toFixed(2)})` }],
@@ -140,9 +149,64 @@ async function main() {
140
149
  }
141
150
  );
142
151
 
152
+ server.tool(
153
+ 'memory_stats',
154
+ 'Return storage statistics for the Aquifer memory store (session counts by status, summaries, turn embeddings, entities, date range).',
155
+ {},
156
+ async () => {
157
+ try {
158
+ const aquifer = getAquifer();
159
+ const stats = await aquifer.getStats();
160
+ const lines = [
161
+ `Sessions: ${stats.sessionTotal} total`,
162
+ ];
163
+ for (const [status, count] of Object.entries(stats.sessions)) {
164
+ lines.push(` ${status}: ${count}`);
165
+ }
166
+ lines.push(`Summaries: ${stats.summaries}`);
167
+ lines.push(`Turn embeddings: ${stats.turnEmbeddings}`);
168
+ lines.push(`Entities: ${stats.entities}`);
169
+ if (stats.earliest) lines.push(`Date range: ${new Date(stats.earliest).toISOString().slice(0, 10)} → ${new Date(stats.latest).toISOString().slice(0, 10)}`);
170
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
171
+ } catch (err) {
172
+ return {
173
+ content: [{ type: 'text', text: `memory_stats error: ${err.message}` }],
174
+ isError: true,
175
+ };
176
+ }
177
+ }
178
+ );
179
+
180
+ server.tool(
181
+ 'memory_pending',
182
+ 'List sessions with pending or failed processing status.',
183
+ {
184
+ limit: z.number().int().min(1).max(200).optional().describe('Max results (default 20)'),
185
+ },
186
+ async (params) => {
187
+ try {
188
+ const aquifer = getAquifer();
189
+ const rows = await aquifer.getPendingSessions({ limit: params.limit ?? 20 });
190
+ if (rows.length === 0) {
191
+ return { content: [{ type: 'text', text: 'No pending or failed sessions.' }] };
192
+ }
193
+ const lines = [`${rows.length} pending/failed session(s):\n`];
194
+ for (const row of rows) {
195
+ lines.push(`${row.session_id} [${row.processing_status}] agent=${row.agent_id}`);
196
+ }
197
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
198
+ } catch (err) {
199
+ return {
200
+ content: [{ type: 'text', text: `memory_pending error: ${err.message}` }],
201
+ isError: true,
202
+ };
203
+ }
204
+ }
205
+ );
206
+
143
207
  // Graceful shutdown
144
208
  const cleanup = async () => {
145
- if (_aquifer?._pool) await _aquifer._pool.end().catch(() => {});
209
+ if (_aquifer) await _aquifer.close().catch(() => {});
146
210
  process.exit(0);
147
211
  };
148
212
  process.on('SIGINT', cleanup);
@@ -153,7 +217,7 @@ async function main() {
153
217
 
154
218
  // Clean up pool when transport closes (stdin EOF)
155
219
  transport.onclose = async () => {
156
- if (_aquifer?._pool) await _aquifer._pool.end().catch(() => {});
220
+ if (_aquifer) await _aquifer.close().catch(() => {});
157
221
  };
158
222
  }
159
223
 
@@ -1,11 +1,17 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Aquifer Memory — OpenClaw Plugin
4
+ * Aquifer Memory — OpenClaw Host Adapter
5
5
  *
6
- * Auto-captures sessions on before_reset and provides session_recall tool.
7
- * Install: add to openclaw.json plugins or extensions directory.
6
+ * Ingest adapter: auto-captures sessions on before_reset.
7
+ * Tool adapter: exposes session_recall/session_feedback via OpenClaw registerTool().
8
+ *
9
+ * Status: COMPATIBILITY ONLY. The official tool delivery path is mcp.servers.aquifer
10
+ * (see consumers/mcp.js). registerTool() exposure has OpenClaw upstream limitations
11
+ * that prevent reliable tool visibility. This plugin is retained for before_reset
12
+ * session capture; tool registration code is kept for future upstream fixes.
8
13
  *
14
+ * Install: add to openclaw.json plugins or extensions directory.
9
15
  * Config via plugin config, environment variables, or aquifer.config.json.
10
16
  */
11
17
 
@@ -169,6 +175,10 @@ module.exports = {
169
175
  } catch (enrichErr) {
170
176
  api.logger.warn(`[aquifer-memory] enrich failed for ${sessionId}: ${enrichErr.message}`);
171
177
  }
178
+ } else {
179
+ try {
180
+ await aquifer.skip(sessionId, { agentId, reason: `user_count=${norm.userCount} < min=${minUserMessages}` });
181
+ } catch (e) { api.logger.warn(`[aquifer-memory] skip failed for ${sessionId}: ${e.message}`); }
172
182
  }
173
183
 
174
184
  recentlyProcessed.set(dedupKey, Date.now());
@@ -189,8 +199,6 @@ module.exports = {
189
199
 
190
200
  // --- session_recall tool ---
191
201
 
192
- // --- session_recall tool ---
193
-
194
202
  api.registerTool((ctx) => {
195
203
  if ((ctx?.sessionKey || '').includes('subagent')) return null;
196
204
 
@@ -208,6 +216,7 @@ module.exports = {
208
216
  dateTo: { type: 'string', description: 'End date YYYY-MM-DD' },
209
217
  entities: { type: 'array', items: { type: 'string' }, description: 'Entity names to match' },
210
218
  entityMode: { type: 'string', enum: ['any', 'all'], description: '"any" (default, boost) or "all" (only sessions with every entity)' },
219
+ mode: { type: 'string', enum: ['fts', 'hybrid', 'vector'], description: 'Recall mode: "fts" (keyword only), "hybrid" (default), "vector" (vector only)' },
211
220
  },
212
221
  required: ['query'],
213
222
  },
@@ -225,6 +234,7 @@ module.exports = {
225
234
  recallOpts.entities = params.entities;
226
235
  recallOpts.entityMode = params.entityMode || 'any';
227
236
  }
237
+ if (params.mode) recallOpts.mode = params.mode;
228
238
 
229
239
  const results = await aquifer.recall(params.query, recallOpts);
230
240
  const text = formatRecallResults(results);
@@ -253,14 +263,17 @@ module.exports = {
253
263
  sessionId: { type: 'string', description: 'Session ID to give feedback on' },
254
264
  verdict: { type: 'string', enum: ['helpful', 'unhelpful'], description: 'Was the recalled session useful?' },
255
265
  note: { type: 'string', description: 'Optional reason' },
266
+ agentId: { type: 'string', description: 'Agent ID the session was stored under (e.g. "main"). Defaults to context agent or "agent" if omitted.' },
256
267
  },
257
268
  required: ['sessionId', 'verdict'],
258
269
  },
259
270
  async execute(_toolCallId, params) {
260
271
  try {
272
+ const resolvedAgentId = params.agentId || ctx?.agentId || undefined;
261
273
  const result = await aquifer.feedback(params.sessionId, {
262
274
  verdict: params.verdict,
263
275
  note: params.note || undefined,
276
+ agentId: resolvedAgentId,
264
277
  });
265
278
  return {
266
279
  content: [{ type: 'text', text: `Feedback: ${result.verdict} (trust ${result.trustBefore.toFixed(2)} → ${result.trustAfter.toFixed(2)})` }],
@@ -33,10 +33,10 @@ const DEFAULTS = {
33
33
  rank: { rrf: 0.65, timeDecay: 0.25, access: 0.10, entityBoost: 0.18 },
34
34
  rerank: {
35
35
  enabled: false,
36
- provider: null, // 'tei' | 'jina' | 'custom'
36
+ provider: null, // 'tei' | 'jina' | 'openrouter' | 'custom'
37
37
  baseUrl: null, // TEI base URL
38
- apiKey: null, // Jina API key
39
- model: null, // Jina model override
38
+ apiKey: null, // Jina / OpenRouter API key
39
+ model: null, // model override (Jina / OpenRouter)
40
40
  topK: 20,
41
41
  maxChars: 1600,
42
42
  timeoutMs: 2000,
@@ -71,12 +71,18 @@ function createAquiferFromConfig(overrides) {
71
71
  if (rc.model) rerankConfig.jinaModel = rc.model;
72
72
  rerankConfig.timeout = rc.timeoutMs || 2000;
73
73
  rerankConfig.maxRetries = rc.maxRetries ?? 1;
74
+ } else if (rc.provider === 'openrouter') {
75
+ rerankConfig.openrouterApiKey = rc.apiKey;
76
+ if (rc.model) rerankConfig.model = rc.model;
77
+ rerankConfig.timeout = rc.timeoutMs || 5000;
78
+ rerankConfig.maxRetries = rc.maxRetries ?? 1;
74
79
  }
75
80
  rerankOpts = rerankConfig;
76
81
  }
77
82
 
78
83
  const aquifer = createAquifer({
79
84
  db: pool,
85
+ ownsPool: true,
80
86
  schema: config.schema,
81
87
  tenantId: config.tenantId,
82
88
  embed: embedFn ? { fn: embedFn, dim: config.embed.dim || null } : null,
@@ -86,10 +92,6 @@ function createAquiferFromConfig(overrides) {
86
92
  rerank: rerankOpts,
87
93
  });
88
94
 
89
- // Attach pool for lifecycle management
90
- aquifer._pool = pool;
91
- aquifer._config = config;
92
-
93
95
  return aquifer;
94
96
  }
95
97
 
package/core/aquifer.js CHANGED
@@ -77,6 +77,7 @@ function createAquifer(config) {
77
77
  ownsPool = true;
78
78
  } else {
79
79
  pool = config.db;
80
+ ownsPool = !!config.ownsPool; // allow factory to claim ownership
80
81
  }
81
82
 
82
83
  // Embed config (lazy — only required for recall/enrich)
@@ -99,8 +100,18 @@ function createAquifer(config) {
99
100
  const entityPromptFn = config.entities && config.entities.prompt ? config.entities.prompt : null;
100
101
  const entityScope = (config.entities && config.entities.scope) || 'default';
101
102
 
102
- // FTS config (default: 'simple'; set to 'zhcfg' for Chinese tokenization)
103
- const ftsConfig = config.ftsConfig || 'simple';
103
+ // FTS config locked to 'simple'.
104
+ // The search_tsv trigger always uses to_tsvector('simple', ...), so query-time
105
+ // config must match. Warn and override if someone passes anything else.
106
+ const _rawFtsConfig = config.ftsConfig || 'simple';
107
+ if (_rawFtsConfig !== 'simple') {
108
+ console.warn(
109
+ `[aquifer] ftsConfig '${_rawFtsConfig}' is not currently supported. ` +
110
+ `The search_tsv index is built with 'simple'; only 'simple' is valid at query time. ` +
111
+ `Overriding to 'simple'.`
112
+ );
113
+ }
114
+ const ftsConfig = 'simple';
104
115
 
105
116
  // Rank weights
106
117
  const rankWeights = {
@@ -551,7 +562,16 @@ function createAquifer(config) {
551
562
 
552
563
  async recall(query, opts = {}) {
553
564
  if (!query) return [];
554
- requireEmbed('recall');
565
+
566
+ const VALID_MODES = ['fts', 'hybrid', 'vector'];
567
+ const mode = opts.mode !== undefined ? opts.mode : 'hybrid';
568
+ if (!VALID_MODES.includes(mode)) {
569
+ throw new Error(`Invalid recall mode: "${mode}". Must be one of: ${VALID_MODES.join(', ')}`);
570
+ }
571
+
572
+ if (mode === 'hybrid' || mode === 'vector') {
573
+ requireEmbed('recall');
574
+ }
555
575
 
556
576
  const {
557
577
  agentId,
@@ -582,10 +602,13 @@ function createAquifer(config) {
582
602
  const rerankTopK = rerankEnabled ? Math.max(limit, opts.rerankTopK || defaultRerankTopK) : limit;
583
603
  const fetchLimit = rerankTopK * 4;
584
604
 
585
- // 1. Embed query
586
- const queryVecResult = await embedFn([query]);
587
- const queryVec = queryVecResult[0];
588
- if (!queryVec || !queryVec.length) return []; // m3: guard empty array too
605
+ // 1. Embed query (only needed for hybrid/vector modes)
606
+ let queryVec = null;
607
+ if (mode === 'hybrid' || mode === 'vector') {
608
+ const queryVecResult = await embedFn([query]);
609
+ queryVec = queryVecResult[0];
610
+ if (!queryVec || !queryVec.length) return []; // m3: guard empty array too
611
+ }
589
612
 
590
613
  // 2. Entity intersection pre-filter (when entityMode === 'all')
591
614
  let candidateSessionIds = null; // null = no filter
@@ -661,17 +684,26 @@ function createAquifer(config) {
661
684
  } catch (_) { /* entity search failure non-fatal */ }
662
685
  }
663
686
 
664
- // 3. Run 3 search paths in parallel
687
+ // 3. Run search paths in parallel (conditioned on mode)
688
+ const runFts = mode === 'fts' || mode === 'hybrid';
689
+ const runVector = mode === 'vector' || mode === 'hybrid';
690
+
665
691
  const [ftsRows, embRows, turnResult] = await Promise.all([
666
- storage.searchSessions(pool, query, {
667
- schema, tenantId, agentIds: resolvedAgentIds, source, dateFrom, dateTo, limit: fetchLimit, ftsConfig,
668
- }).catch(() => []),
669
- embeddingSearchSummaries(queryVec, {
670
- agentIds: resolvedAgentIds, source, dateFrom, dateTo, limit: fetchLimit,
671
- }).catch(() => []),
672
- storage.searchTurnEmbeddings(pool, {
673
- schema, tenantId, queryVec, dateFrom, dateTo, agentIds: resolvedAgentIds, source, limit: fetchLimit,
674
- }).catch(() => ({ rows: [] })),
692
+ runFts
693
+ ? storage.searchSessions(pool, query, {
694
+ schema, tenantId, agentIds: resolvedAgentIds, source, dateFrom, dateTo, limit: fetchLimit, ftsConfig,
695
+ }).catch(() => [])
696
+ : Promise.resolve([]),
697
+ runVector
698
+ ? embeddingSearchSummaries(queryVec, {
699
+ agentIds: resolvedAgentIds, source, dateFrom, dateTo, limit: fetchLimit,
700
+ }).catch(() => [])
701
+ : Promise.resolve([]),
702
+ runVector
703
+ ? storage.searchTurnEmbeddings(pool, {
704
+ schema, tenantId, queryVec, dateFrom, dateTo, agentIds: resolvedAgentIds, source, limit: fetchLimit,
705
+ }).catch(() => ({ rows: [] }))
706
+ : Promise.resolve({ rows: [] }),
675
707
  ]);
676
708
 
677
709
  const turnRows = turnResult.rows || [];
@@ -836,6 +868,27 @@ function createAquifer(config) {
836
868
  return storage.getSession(pool, sessionId, agentId, opts, { schema, tenantId });
837
869
  },
838
870
 
871
+ async skip(sessionId, opts = {}) {
872
+ const agentId = opts.agentId || 'agent';
873
+ const reason = opts.reason || null;
874
+ // Atomic CAS: only skip if still pending (avoids race with concurrent enrich)
875
+ const result = await pool.query(
876
+ `UPDATE ${qi(schema)}.sessions
877
+ SET processing_status = 'skipped', processing_error = $1
878
+ WHERE session_id = $2 AND agent_id = $3 AND tenant_id = $4
879
+ AND processing_status = 'pending'
880
+ RETURNING id`,
881
+ [reason, sessionId, agentId, tenantId]
882
+ );
883
+ if (result.rows.length === 0) {
884
+ // Check if session exists at all
885
+ const existing = await storage.getSession(pool, sessionId, agentId, {}, { schema, tenantId });
886
+ if (!existing) throw new Error(`Session not found: ${sessionId} (agentId=${agentId})`);
887
+ return null; // exists but not pending — no-op
888
+ }
889
+ return { id: result.rows[0].id, sessionId, agentId, status: 'skipped' };
890
+ },
891
+
839
892
  async getSessionFull(sessionId) {
840
893
  // Try to find the session across agents by querying directly
841
894
  const result = await pool.query(
@@ -868,6 +921,93 @@ function createAquifer(config) {
868
921
  summary: sumResult.rows[0] || null,
869
922
  };
870
923
  },
924
+
925
+ // --- public config accessor ---
926
+
927
+ getConfig() {
928
+ return { schema, tenantId };
929
+ },
930
+
931
+ // --- admin query helpers ---
932
+
933
+ async getStats() {
934
+ const [sessions, summaries, turns, timeRange] = await Promise.all([
935
+ pool.query(
936
+ `SELECT processing_status, COUNT(*)::int as count
937
+ FROM ${qi(schema)}.sessions WHERE tenant_id = $1
938
+ GROUP BY processing_status`,
939
+ [tenantId]
940
+ ),
941
+ pool.query(
942
+ `SELECT COUNT(*)::int as count FROM ${qi(schema)}.session_summaries WHERE tenant_id = $1`,
943
+ [tenantId]
944
+ ),
945
+ pool.query(
946
+ `SELECT COUNT(*)::int as count FROM ${qi(schema)}.turn_embeddings WHERE tenant_id = $1`,
947
+ [tenantId]
948
+ ),
949
+ pool.query(
950
+ `SELECT MIN(started_at) as earliest, MAX(started_at) as latest
951
+ FROM ${qi(schema)}.sessions WHERE tenant_id = $1`,
952
+ [tenantId]
953
+ ),
954
+ ]);
955
+
956
+ let entityCount = 0;
957
+ try {
958
+ const entResult = await pool.query(
959
+ `SELECT COUNT(*)::int as count FROM ${qi(schema)}.entities WHERE tenant_id = $1`,
960
+ [tenantId]
961
+ );
962
+ entityCount = entResult.rows[0]?.count || 0;
963
+ } catch (_) { /* entities table may not exist */ }
964
+
965
+ return {
966
+ sessions: Object.fromEntries(sessions.rows.map(r => [r.processing_status, r.count])),
967
+ sessionTotal: sessions.rows.reduce((s, r) => s + r.count, 0),
968
+ summaries: summaries.rows[0]?.count || 0,
969
+ turnEmbeddings: turns.rows[0]?.count || 0,
970
+ entities: entityCount,
971
+ earliest: timeRange.rows[0]?.earliest || null,
972
+ latest: timeRange.rows[0]?.latest || null,
973
+ };
974
+ },
975
+
976
+ async getPendingSessions(opts = {}) {
977
+ const limit = opts.limit !== undefined ? opts.limit : 100;
978
+ const result = await pool.query(
979
+ `SELECT session_id, agent_id, processing_status
980
+ FROM ${qi(schema)}.sessions
981
+ WHERE tenant_id = $1
982
+ AND processing_status IN ('pending', 'failed')
983
+ ORDER BY started_at DESC
984
+ LIMIT $2`,
985
+ [tenantId, limit]
986
+ );
987
+ return result.rows;
988
+ },
989
+
990
+ async exportSessions(opts = {}) {
991
+ const { agentId, source, limit = 1000 } = opts;
992
+ const where = [`s.tenant_id = $1`];
993
+ const params = [tenantId];
994
+
995
+ if (agentId) { params.push(agentId); where.push(`s.agent_id = $${params.length}`); }
996
+ if (source) { params.push(source); where.push(`s.source = $${params.length}`); }
997
+ params.push(limit);
998
+
999
+ const result = await pool.query(
1000
+ `SELECT s.session_id, s.agent_id, s.source, s.started_at, s.msg_count,
1001
+ s.processing_status, ss.summary_text, ss.structured_summary
1002
+ FROM ${qi(schema)}.sessions s
1003
+ LEFT JOIN ${qi(schema)}.session_summaries ss ON ss.session_row_id = s.id
1004
+ WHERE ${where.join(' AND ')}
1005
+ ORDER BY s.started_at DESC
1006
+ LIMIT $${params.length}`,
1007
+ params
1008
+ );
1009
+ return result.rows;
1010
+ },
871
1011
  };
872
1012
 
873
1013
  return aquifer;
package/core/storage.js CHANGED
@@ -31,7 +31,7 @@ const TURN_NOISE_RE = [
31
31
  /^A new session was started via \/new/,
32
32
  ];
33
33
 
34
- const VALID_STATUSES = new Set(['pending', 'processing', 'succeeded', 'partial', 'failed']);
34
+ const VALID_STATUSES = new Set(['pending', 'processing', 'succeeded', 'partial', 'failed', 'skipped']);
35
35
 
36
36
  // ---------------------------------------------------------------------------
37
37
  // upsertSession
@@ -339,8 +339,17 @@ async function searchSessions(pool, query, {
339
339
  ftsConfig = 'simple',
340
340
  } = {}) {
341
341
  const clampedLimit = Math.max(1, Math.min(100, limit));
342
- // Sanitize ftsConfig to prevent SQL injection (must be a valid regconfig name)
343
- const safeFts = /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(ftsConfig) ? ftsConfig : 'simple';
342
+ // FTS config is locked to 'simple' the search_tsv trigger always uses
343
+ // to_tsvector('simple', ...) so query semantics must match. Warn callers
344
+ // that pass a different value rather than silently honouring it.
345
+ if (ftsConfig !== 'simple') {
346
+ console.warn(
347
+ `[aquifer/storage] searchSessions: ftsConfig '${ftsConfig}' ignored. ` +
348
+ `Only 'simple' is supported (index is built with simple tokenizer). ` +
349
+ `Using 'simple'.`
350
+ );
351
+ }
352
+ const safeFts = 'simple';
344
353
 
345
354
  // Normalize agentId/agentIds
346
355
  const agentIds = rawAgentIds && rawAgentIds.length > 0