@exulu/backend 1.69.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/{catalog-TBSPSN2N.js → catalog-UGTDNMDM.js} +2 -1
  2. package/dist/{chunk-YCE44CMU.js → chunk-7CCMW3IW.js} +2 -0
  3. package/dist/chunk-IJ4HNHOT.js +6416 -0
  4. package/dist/{chunk-IDHS2BZO.js → chunk-T6JVFT7L.js} +2 -0
  5. package/dist/cli/start-whisper.cjs +1 -0
  6. package/dist/cli/start-whisper.js +2 -1
  7. package/dist/convert-exulu-tools-to-ai-sdk-tools-2PEDFZ2X.js +9 -0
  8. package/dist/index.cjs +9558 -9262
  9. package/dist/index.d.cts +46 -29
  10. package/dist/index.d.ts +46 -29
  11. package/dist/index.js +4989 -548
  12. package/ee/agentic-retrieval/pipeline/config.test.ts +81 -0
  13. package/ee/agentic-retrieval/pipeline/config.ts +189 -0
  14. package/ee/agentic-retrieval/pipeline/hyde.test.ts +55 -0
  15. package/ee/agentic-retrieval/pipeline/hyde.ts +133 -0
  16. package/ee/agentic-retrieval/pipeline/index.test.ts +140 -0
  17. package/ee/agentic-retrieval/pipeline/index.ts +638 -0
  18. package/ee/agentic-retrieval/pipeline/memory.test.ts +101 -0
  19. package/ee/agentic-retrieval/pipeline/memory.ts +566 -0
  20. package/ee/agentic-retrieval/pipeline/multi-query.test.ts +51 -0
  21. package/ee/agentic-retrieval/pipeline/multi-query.ts +158 -0
  22. package/ee/agentic-retrieval/pipeline/prefilter.test.ts +93 -0
  23. package/ee/agentic-retrieval/pipeline/prefilter.ts +389 -0
  24. package/ee/agentic-retrieval/pipeline/rerank.test.ts +128 -0
  25. package/ee/agentic-retrieval/pipeline/rerank.ts +178 -0
  26. package/ee/agentic-retrieval/pipeline/routing.test.ts +144 -0
  27. package/ee/agentic-retrieval/pipeline/routing.ts +343 -0
  28. package/ee/agentic-retrieval/pipeline/search.test.ts +149 -0
  29. package/ee/agentic-retrieval/pipeline/search.ts +180 -0
  30. package/ee/agentic-retrieval/pipeline/text-utils.test.ts +43 -0
  31. package/ee/agentic-retrieval/pipeline/text-utils.ts +85 -0
  32. package/ee/agentic-retrieval/pipeline/types.ts +59 -0
  33. package/ee/python/documents/processing/doc_processor.ts +1 -1
  34. package/ee/python/documents/processing/split_pdf.py +78 -24
  35. package/package.json +2 -1
  36. package/dist/chunk-WCP3WZM3.js +0 -10391
  37. package/dist/convert-exulu-tools-to-ai-sdk-tools-GQ3UIYP7.js +0 -6
  38. package/ee/agentic-retrieval/v3/agent-loop.ts +0 -288
  39. package/ee/agentic-retrieval/v3/classifier.ts +0 -92
  40. package/ee/agentic-retrieval/v3/context-sampler.ts +0 -79
  41. package/ee/agentic-retrieval/v3/dynamic-tools.ts +0 -115
  42. package/ee/agentic-retrieval/v3/index.ts +0 -471
  43. package/ee/agentic-retrieval/v3/session-tools-registry.ts +0 -20
  44. package/ee/agentic-retrieval/v3/strategies.ts +0 -171
  45. package/ee/agentic-retrieval/v3/tools.ts +0 -558
  46. package/ee/agentic-retrieval/v3/trajectory.ts +0 -309
  47. package/ee/agentic-retrieval/v3/types.ts +0 -59
@@ -1,558 +0,0 @@
1
- import { z } from "zod";
2
- import { tool } from "ai";
3
- import type { ExuluContext } from "@SRC/exulu/context";
4
- import { getTableName, getChunksTableName } from "@SRC/exulu/context";
5
- import { postgresClient } from "@SRC/postgres/client";
6
- import { applyFilters } from "@SRC/graphql/resolvers/apply-filters";
7
- import { applyAccessControl } from "@SRC/graphql/utilities/access-control";
8
- import { convertContextToTableDefinition } from "@SRC/graphql/utilities/convert-context-to-table-definition";
9
- import type { SearchFilters } from "@SRC/graphql/types";
10
- import type { VectorSearchChunkResult } from "@SRC/graphql/resolvers/vector-search";
11
- import type { User } from "@EXULU_TYPES/models/user";
12
- import type { ChunkResult } from "./types";
13
-
14
- function buildContextEnum(contexts: ExuluContext[]) {
15
- return z
16
- .array(z.enum(contexts.map((c) => c.id) as [string, ...string[]]))
17
- .describe(
18
- contexts
19
- .map(
20
- (c) =>
21
- `<knowledge_base id="${c.id}" name="${c.name}">${c.description}</knowledge_base>`,
22
- )
23
- .join("\n"),
24
- );
25
- }
26
-
27
- function resolveContexts(
28
- ids: string[],
29
- all: ExuluContext[],
30
- ): ExuluContext[] {
31
- if (!ids?.length) return all;
32
- return ids.map((id) => {
33
- const ctx = all.find(
34
- (c) => c.id === id || c.id.toLowerCase().includes(id.toLowerCase()),
35
- );
36
- if (!ctx) throw new Error(`Knowledge base not found: ${id}`);
37
- return ctx;
38
- });
39
- }
40
-
41
- function mapSearchMethod(method: "hybrid" | "keyword" | "semantic"): "hybridSearch" | "tsvector" | "cosineDistance" {
42
- if (method === "hybrid") return "hybridSearch";
43
- if (method === "keyword") return "tsvector";
44
- return "cosineDistance";
45
- }
46
-
47
- /**
48
- * Parses session item entries into a per-context map.
49
- *
50
- * Two supported formats:
51
- * "<context_id>/<item_id>" → specific item; value is a non-empty string[]
52
- * "<context_id>" → full context (no item filter); value is null
53
- *
54
- * If both a full-context entry and specific-item entries exist for the same
55
- * context, full-context (null) wins.
56
- */
57
- export function parseGlobalItemIds(globalIds: string[]): Map<string, string[] | null> {
58
- const map = new Map<string, string[] | null>();
59
- for (const gid of globalIds) {
60
- const slashIdx = gid.indexOf("/");
61
- if (slashIdx === -1) {
62
- // No slash → entire context selected
63
- if (gid) map.set(gid, null);
64
- continue;
65
- }
66
- const contextId = gid.slice(0, slashIdx);
67
- const itemId = gid.slice(slashIdx + 1);
68
- if (!contextId || !itemId) continue;
69
- // Full-context entry already wins — don't downgrade to specific items
70
- if (map.get(contextId) === null) continue;
71
- const existing = map.get(contextId) ?? [];
72
- existing.push(itemId);
73
- map.set(contextId, existing);
74
- }
75
- return map;
76
- }
77
-
78
- export type RetrievalToolParams = {
79
- contexts: ExuluContext[];
80
- toolVariablesConfig?: Record<string, any>;
81
- user?: User;
82
- role?: string;
83
- updateVirtualFiles: (files: Array<{ path: string; content: string }>) => Promise<void>;
84
- /**
85
- * Preselected scope keyed by context ID. When set, every tool is scoped accordingly:
86
- * null → full context access (no item filter)
87
- * string[] → only these specific item IDs
88
- * missing key → context was not selected; return empty results
89
- */
90
- preselectedItemsByContext?: Map<string, string[] | null>;
91
- };
92
-
93
- /**
94
- * Creates all pre-built retrieval tools. These are passed to the agent loop
95
- * and filtered per strategy.
96
- */
97
- export function createRetrievalTools(params: RetrievalToolParams) {
98
- const { contexts, toolVariablesConfig, user, role, updateVirtualFiles, preselectedItemsByContext } = params;
99
- const ctxEnum = buildContextEnum(contexts);
100
-
101
- // ──────────────────────────────────────────────────────────
102
- // count_items_or_chunks
103
- // ──────────────────────────────────────────────────────────
104
- const count_items_or_chunks = tool({
105
- description:
106
- "Count items or chunks WITHOUT loading them into context. Use for 'how many', 'count', or 'total number of' queries.",
107
- inputSchema: z.object({
108
- knowledge_base_ids: ctxEnum,
109
- count_what: z
110
- .enum(["items", "chunks"])
111
- .describe("Whether to count items (documents) or chunks (pages/sections)"),
112
- name_contains: z
113
- .string()
114
- .optional()
115
- .describe("Only count items whose name contains this text (case-insensitive)"),
116
- content_query: z
117
- .string()
118
- .optional()
119
- .describe(
120
- "Only count chunks matching this search query (uses hybrid search). Only used when count_what is 'chunks'.",
121
- ),
122
- }),
123
- execute: async ({ knowledge_base_ids, count_what, name_contains, content_query }) => {
124
- const { db } = await postgresClient();
125
- const ctxList = resolveContexts(knowledge_base_ids, contexts);
126
-
127
- const counts = await Promise.all(
128
- ctxList.map(async (ctx) => {
129
- const contextItemIds = preselectedItemsByContext?.get(ctx.id);
130
- // undefined = context not in preselection map → skip
131
- if (preselectedItemsByContext && contextItemIds === undefined) {
132
- return { context: ctx.id, context_name: ctx.name, count: 0 };
133
- }
134
- // null = full context; string[] = specific items
135
-
136
- let count = 0;
137
-
138
- if (count_what === "items") {
139
- const tableName = getTableName(ctx.id);
140
- let q = db(tableName).count("id as count").whereNull("archived");
141
- if (name_contains) {
142
- q = q.whereRaw("LOWER(name) LIKE ?", [`%${name_contains.toLowerCase()}%`]);
143
- }
144
- if (Array.isArray(contextItemIds)) {
145
- q = q.whereIn("id", contextItemIds);
146
- }
147
- const tableDefinition = convertContextToTableDefinition(ctx);
148
- q = applyAccessControl(tableDefinition, q, user, tableName);
149
- const result = await q.first();
150
- count = Number(result?.count ?? 0);
151
- } else {
152
- const chunksTable = getChunksTableName(ctx.id);
153
- const baseItemFilters: SearchFilters = Array.isArray(contextItemIds)
154
- ? [{ id: { in: contextItemIds } }]
155
- : [];
156
- if (content_query) {
157
- const searchResults = await ctx.search({
158
- query: content_query,
159
- method: "hybridSearch",
160
- limit: 10000,
161
- page: 1,
162
- itemFilters: baseItemFilters,
163
- chunkFilters: [],
164
- sort: { field: "updatedAt", direction: "desc" },
165
- user,
166
- role,
167
- trigger: "tool",
168
- });
169
- count = searchResults.chunks.length;
170
- } else if (Array.isArray(contextItemIds)) {
171
- const result = await db(chunksTable).count("id as count").whereIn("source", contextItemIds).first();
172
- count = Number(result?.count ?? 0);
173
- } else {
174
- const result = await db(chunksTable).count("id as count").first();
175
- count = Number(result?.count ?? 0);
176
- }
177
- }
178
-
179
- return { context: ctx.id, context_name: ctx.name, count };
180
- }),
181
- );
182
-
183
- return JSON.stringify({
184
- total_count: counts.reduce((s, c) => s + c.count, 0),
185
- breakdown_by_context: counts,
186
- });
187
- },
188
- });
189
-
190
- // ──────────────────────────────────────────────────────────
191
- // search_items_by_name
192
- // ──────────────────────────────────────────────────────────
193
- const search_items_by_name = tool({
194
- description:
195
- "Search for items by their name or external ID. Use when:\n" +
196
- "• The user asks for a document BY TITLE or NAME\n" +
197
- "• The user asks whether a specific named document EXISTS (e.g. 'do you have the X manual?', 'is there a document for Y?')\n" +
198
- "• Any query that references a specific document, manual, or resource by its name rather than by topic\n" +
199
- "Do NOT use for topic-based content queries (e.g. 'what are the parameters for X?', 'how do I configure Y?').",
200
- inputSchema: z.object({
201
- knowledge_base_ids: ctxEnum,
202
- item_name: z.string().describe(
203
- "The name or partial name to search for. Uses substring matching, so shorter and more specific terms work better than full phrases. " +
204
- "Extract only the core identifying part — typically the product model, document title, or unique identifier. " +
205
- "Do NOT include surrounding descriptors like type words ('manual', 'guide', 'document') or manufacturer names unless they are likely part of the actual document title."
206
- ),
207
- limit: z
208
- .number()
209
- .default(100)
210
- .describe(
211
- "Max items per knowledge base (max 400). Applies independently to each knowledge base.",
212
- ),
213
- }),
214
- execute: async ({ item_name, limit, knowledge_base_ids }) => {
215
- const { db } = await postgresClient();
216
- const ctxList = resolveContexts(knowledge_base_ids, contexts);
217
- const safeLimit = Math.min(limit ?? 100, 400);
218
-
219
- const results = await Promise.all(
220
- ctxList.map(async (ctx) => {
221
- const contextItemIds = preselectedItemsByContext?.get(ctx.id);
222
- // undefined = context not in preselection map → skip
223
- if (preselectedItemsByContext && contextItemIds === undefined) return [];
224
-
225
- const itemFilters: SearchFilters = item_name ? [{ name: { contains: item_name } }] : [];
226
- if (Array.isArray(contextItemIds)) itemFilters.push({ id: { in: contextItemIds } });
227
-
228
- const tableName = getTableName(ctx.id);
229
- const tableDefinition = convertContextToTableDefinition(ctx);
230
-
231
- let q = db(`${tableName} as items`).select([
232
- "items.id as item_id",
233
- "items.name as item_name",
234
- "items.external_id as item_external_id",
235
- db.raw('items."updatedAt" as item_updated_at'),
236
- db.raw('items."createdAt" as item_created_at'),
237
- ...ctx.fields.map((f) => `items.${f.name} as ${f.name}`),
238
- ]);
239
- q = q.limit(safeLimit);
240
- q = applyFilters(q, itemFilters, tableDefinition, "items");
241
- q = applyAccessControl(tableDefinition, q, user, "items");
242
- const items = await q;
243
-
244
- return Promise.all(
245
- items.map(async (item) => {
246
- const chunksTable = getChunksTableName(ctx.id);
247
- const chunks = await db(chunksTable)
248
- .select(["id", "source", "metadata"])
249
- .where("source", item.item_id)
250
- .limit(1);
251
-
252
- if (!chunks[0]) return null;
253
- return {
254
- item_name: item.item_name,
255
- item_id: item.item_id,
256
- context: ctx.id,
257
- chunk_id: chunks[0].id,
258
- chunk_index: 1,
259
- metadata: chunks[0].metadata,
260
- } satisfies ChunkResult;
261
- }),
262
- );
263
- }),
264
- );
265
-
266
- return JSON.stringify(results.flat().filter(Boolean));
267
- },
268
- });
269
-
270
- // ──────────────────────────────────────────────────────────
271
- // search_content
272
- // ──────────────────────────────────────────────────────────
273
- const search_content = tool({
274
- description: `Search ONE knowledge base for document content using hybrid, keyword, or semantic search.
275
- Always make a separate call for each knowledge base you want to search — never bundle multiple in one call.
276
-
277
- Use includeContent: false when you only need to know WHICH documents match (listing, overview, navigation).
278
- Use includeContent: true when you need the ACTUAL text to answer a question.
279
-
280
- For listing queries: always start with includeContent: false, then use dynamic tools to fetch specific pages.`,
281
- inputSchema: z.object({
282
- userQuery: z.string().describe("The original unaltered question from the user"),
283
- knowledge_base_id: z
284
- .enum(contexts.map((c) => c.id) as [string, ...string[]])
285
- .describe(
286
- contexts
287
- .map(
288
- (c) =>
289
- `<knowledge_base id="${c.id}" name="${c.name}">${c.description}</knowledge_base>`,
290
- )
291
- .join("\n"),
292
- ),
293
- keywords: z.array(z.string()).optional().describe("Keywords extracted from the query"),
294
- searchMethod: z
295
- .enum(["hybrid", "keyword", "semantic"])
296
- .default("hybrid")
297
- .describe(
298
- "hybrid: best default (semantic + keyword). keyword: exact terms, product codes, IDs. semantic: conceptual/synonyms.",
299
- ),
300
- includeContent: z
301
- .boolean()
302
- .default(true)
303
- .describe(
304
- "false: returns metadata only (document names, scores) — use for listing/navigation. " +
305
- "true: returns full chunk text — use when you need content to answer a question.",
306
- ),
307
- item_ids: z.array(z.string()).optional().describe("Filter results to specific item IDs"),
308
- item_names: z
309
- .array(z.string())
310
- .optional()
311
- .describe("Filter results to items whose name contains one of these strings"),
312
- item_external_ids: z
313
- .array(z.string())
314
- .optional()
315
- .describe("Filter results to specific external IDs"),
316
- limit: z
317
- .number()
318
- .default(20)
319
- .describe("Max chunks with content (max 20). Without content, up to 200 are returned."),
320
- }),
321
- execute: async ({
322
- userQuery,
323
- knowledge_base_id,
324
- keywords,
325
- searchMethod,
326
- includeContent,
327
- item_ids,
328
- item_names,
329
- item_external_ids,
330
- limit,
331
- }) => {
332
- const [ctx] = resolveContexts([knowledge_base_id], contexts) as [ExuluContext];
333
- const maxResults = toolVariablesConfig?.[`${ctx.id}_|_max_results`] || 20;
334
- const effectiveLimit = includeContent ? Math.min(limit ?? maxResults, maxResults) : Math.min((limit ?? maxResults) * maxResults, 400);
335
-
336
- const itemFilters: SearchFilters = [];
337
-
338
- if (preselectedItemsByContext) {
339
- const contextItemIds = preselectedItemsByContext.get(knowledge_base_id);
340
- if (contextItemIds === undefined) {
341
- // Context not in preselection map — nothing to search
342
- return JSON.stringify([]);
343
- }
344
- if (Array.isArray(contextItemIds)) {
345
- const intersection = item_ids?.length
346
- ? item_ids.filter((id) => contextItemIds.includes(id))
347
- : contextItemIds;
348
- if (!intersection.length) {
349
- // Agent specified item_ids entirely outside the preselected scope
350
- return JSON.stringify([]);
351
- }
352
- itemFilters.push({ id: { in: intersection } });
353
- }
354
- // null = full context → no item filter; agent's item_ids still respected if provided
355
- else if (item_ids?.length) {
356
- itemFilters.push({ id: { in: item_ids } });
357
- }
358
- } else if (item_ids?.length) {
359
- itemFilters.push({ id: { in: item_ids } });
360
- }
361
-
362
- if (item_names)
363
- itemFilters.push({ name: { or: item_names.map((n) => ({ contains: n })) } });
364
- if (item_external_ids) itemFilters.push({ external_id: { in: item_external_ids } });
365
-
366
- const effectiveQuery = userQuery || keywords?.join(" ") || "";
367
-
368
- let method = mapSearchMethod(searchMethod ?? "hybrid");
369
-
370
- if (method === "hybridSearch" || method === "cosineDistance") {
371
- if (!ctx.embedder) {
372
- console.error(`[EXULU] context "${ctx.id}" does not have an embedder, falling back to tsvector search`);
373
- method = "tsvector";
374
- }
375
- }
376
-
377
- const expandChunks = toolVariablesConfig?.[`${ctx.id}_|_expand_chunks`] || 0;
378
-
379
- try {
380
- const { chunks } = await ctx.search({
381
- query: effectiveQuery,
382
- keywords,
383
- method,
384
- limit: effectiveLimit,
385
- page: 1,
386
- itemFilters,
387
- chunkFilters: [],
388
- sort: { field: "updatedAt", direction: "desc" },
389
- user,
390
- role,
391
- trigger: "tool",
392
- expand: expandChunks > 0 ? {
393
- before: expandChunks,
394
- after: expandChunks,
395
- } : undefined,
396
- });
397
-
398
- return JSON.stringify(
399
- chunks.map(
400
- (chunk): ChunkResult => ({
401
- item_name: chunk.item_name,
402
- item_id: chunk.item_id,
403
- context: chunk.context?.id ?? ctx.id,
404
- chunk_id: chunk.chunk_id,
405
- chunk_index: chunk.chunk_index,
406
- chunk_content: includeContent ? chunk.chunk_content : undefined,
407
- metadata: {
408
- ...chunk.chunk_metadata,
409
- cosine_distance: chunk.chunk_cosine_distance,
410
- fts_rank: chunk.chunk_fts_rank,
411
- hybrid_score: chunk.chunk_hybrid_score,
412
- },
413
- }),
414
- ),
415
- );
416
- } catch (err) {
417
- console.error(`[EXULU] search_content failed for context "${ctx.id}":`, err);
418
- return JSON.stringify([]);
419
- }
420
- },
421
- });
422
-
423
- // ──────────────────────────────────────────────────────────
424
- // save_search_results
425
- // ──────────────────────────────────────────────────────────
426
- const save_search_results = tool({
427
- description: `Execute a search on ONE knowledge base and save ALL results to the virtual filesystem WITHOUT loading them into context.
428
- Always make a separate call for each knowledge base you want to search.
429
-
430
- Use this when you expect many results (>20) and need to filter iteratively:
431
- 1. Call save_search_results (once per knowledge base) to save up to 1000 results to /search_results_{knowledge_base_id}.txt
432
- 2. Use bash grep/awk to identify relevant chunks by pattern
433
- 3. Use dynamic get_content tools to load only the specific chunks you need
434
-
435
- The saved file format:
436
- ### RESULT N ###
437
- ITEM_NAME: ...
438
- ITEM_ID: ...
439
- CHUNK_ID: ...
440
- CHUNK_INDEX: ...
441
- CONTEXT: ...
442
- SCORE: ...
443
- ---CONTENT START---
444
- (content or placeholder)
445
- ---CONTENT END---`,
446
- inputSchema: z.object({
447
- knowledge_base_id: z
448
- .enum(contexts.map((c) => c.id) as [string, ...string[]])
449
- .describe(
450
- contexts
451
- .map(
452
- (c) =>
453
- `<knowledge_base id="${c.id}" name="${c.name}">${c.description}</knowledge_base>`,
454
- )
455
- .join("\n"),
456
- ),
457
- query: z.string().describe("Search query"),
458
- searchMethod: z.enum(["hybrid", "keyword", "semantic"]).default("hybrid"),
459
- limit: z
460
- .number()
461
- .max(1000)
462
- .default(100)
463
- .describe("Max results to save (max 1000)"),
464
- includeContent: z
465
- .boolean()
466
- .default(true)
467
- .describe(
468
- "Whether to include chunk text in the saved file. False saves tokens — use true only if you need to grep content.",
469
- ),
470
- }),
471
- execute: async ({ query, knowledge_base_id, searchMethod, limit, includeContent }) => {
472
- const [ctx] = resolveContexts([knowledge_base_id], contexts) as [ExuluContext];
473
-
474
- const contextItemIds = preselectedItemsByContext?.get(knowledge_base_id);
475
- // undefined = context not in preselection map → skip
476
- if (preselectedItemsByContext && contextItemIds === undefined) {
477
- return JSON.stringify({
478
- success: true,
479
- results_count: 0,
480
- message: `Context "${knowledge_base_id}" not in preselected scope — skipped.`,
481
- });
482
- }
483
-
484
- // null = full context (no filter); string[] = specific items
485
- const itemFilters: SearchFilters = Array.isArray(contextItemIds)
486
- ? [{ id: { in: contextItemIds } }]
487
- : [];
488
-
489
- let chunks: VectorSearchChunkResult[] = [];
490
- try {
491
- const result = await ctx.search({
492
- query,
493
- method: mapSearchMethod(searchMethod ?? "hybrid"),
494
- limit: Math.min(limit ?? 100, 1000),
495
- page: 1,
496
- itemFilters,
497
- chunkFilters: [],
498
- sort: { field: "updatedAt", direction: "desc" },
499
- user,
500
- role,
501
- trigger: "tool",
502
- });
503
- chunks = result.chunks;
504
- } catch (err) {
505
- console.error(`[EXULU] save_search_results failed for context "${ctx.id}":`, err);
506
- }
507
-
508
- const fileName = `search_results_${ctx.id}.txt`;
509
- const fileContent = chunks
510
- .map(
511
- (chunk, i) =>
512
- `### RESULT ${i + 1} ###\n` +
513
- `ITEM_NAME: ${chunk.item_name}\n` +
514
- `ITEM_ID: ${chunk.item_id}\n` +
515
- `CHUNK_ID: ${chunk.chunk_id}\n` +
516
- `CHUNK_INDEX: ${chunk.chunk_index}\n` +
517
- `CONTEXT: ${chunk.context?.id ?? ""}\n` +
518
- `SCORE: ${chunk.chunk_hybrid_score ?? chunk.chunk_fts_rank ?? chunk.chunk_cosine_distance ?? 0}\n` +
519
- `---CONTENT START---\n` +
520
- `${includeContent && chunk.chunk_content ? chunk.chunk_content : "[use includeContent: true or get_content tool to load]"}\n` +
521
- `---CONTENT END---\n`,
522
- )
523
- .join("\n");
524
-
525
- await updateVirtualFiles([
526
- { path: fileName, content: fileContent },
527
- {
528
- path: `search_metadata_${ctx.id}.json`,
529
- content: JSON.stringify({
530
- query,
531
- timestamp: new Date().toISOString(),
532
- results_count: chunks.length,
533
- context: ctx.id,
534
- method: searchMethod,
535
- }),
536
- },
537
- ]);
538
-
539
- return JSON.stringify({
540
- success: true,
541
- results_count: chunks.length,
542
- message: `Saved ${chunks.length} results to /${fileName}`,
543
- grep_examples: [
544
- `grep -i 'keyword' ${fileName} | head -20`,
545
- `grep 'ITEM_NAME:' ${fileName}`,
546
- `grep -B 5 'pattern' ${fileName} | grep 'CHUNK_ID:'`,
547
- ],
548
- });
549
- },
550
- });
551
-
552
- return {
553
- count_items_or_chunks,
554
- search_items_by_name,
555
- search_content,
556
- save_search_results,
557
- };
558
- }