From d80c550d2eb198f3f7a47049a8952e7d05c5076b Mon Sep 17 00:00:00 2001 From: root Date: Mon, 1 Jun 2026 18:12:03 +1000 Subject: [PATCH] feat(ai): search + read grounding tools Co-Authored-By: Claude Opus 4.8 --- lib/ai/agent/tools/read.js | 23 ++++++++++++++ lib/ai/agent/tools/search.js | 28 +++++++++++++++++ tests/ai/agent/tools/read_search.test.js | 39 ++++++++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 lib/ai/agent/tools/read.js create mode 100644 lib/ai/agent/tools/search.js create mode 100644 tests/ai/agent/tools/read_search.test.js diff --git a/lib/ai/agent/tools/read.js b/lib/ai/agent/tools/read.js new file mode 100644 index 0000000..c9764fc --- /dev/null +++ b/lib/ai/agent/tools/read.js @@ -0,0 +1,23 @@ +import { pool } from '../../../db/pool.js'; + +const TABLE = { page: 'pages', ref: 'refs', task: 'tasks', conversation: 'conversations' }; + +export const readTool = { + name: 'read', + description: 'Read a single entity (page, ref, task, conversation) by id for grounding.', + input_schema: { + type: 'object', + properties: { + kind: { type: 'string', enum: ['page', 'ref', 'task', 'conversation'] }, + id: { type: 'string', description: 'uuid of the entity' } + }, + required: ['kind', 'id'] + }, + async handler({ kind, id }, _ctx) { + const table = TABLE[kind]; + if (!table) return { error: `unknown kind "${kind}"` }; + const { rows: [row] } = await pool.query(`SELECT * FROM ${table} WHERE id=$1`, [id]); + if (!row) return { error: `${kind} ${id} not found` }; + return row; + } +}; diff --git a/lib/ai/agent/tools/search.js b/lib/ai/agent/tools/search.js new file mode 100644 index 0000000..7ae9dc0 --- /dev/null +++ b/lib/ai/agent/tools/search.js @@ -0,0 +1,28 @@ +import * as searchRepo from '../../../db/repos/search.js'; + +export const searchTool = { + name: 'search', + description: 'Full-text search across pages, refs, source docs and messages in the current Space. Use to find information before answering.', + input_schema: { + type: 'object', + properties: { + q: { type: 'string', description: 'search query' }, + kinds: { + type: 'array', + items: { type: 'string', enum: ['page', 'ref', 'source_doc', 'message'] }, + description: 'optional filter of result kinds' + } + }, + required: ['q'] + }, + async handler({ q, kinds }, ctx) { + const results = await searchRepo.fts({ + q, + space_id: ctx.space_id ?? null, + kinds: kinds?.length ? kinds : null, + limit: 8, + offset: 0 + }); + return { results }; + } +}; diff --git a/tests/ai/agent/tools/read_search.test.js b/tests/ai/agent/tools/read_search.test.js new file mode 100644 index 0000000..6c8342e --- /dev/null +++ b/tests/ai/agent/tools/read_search.test.js @@ -0,0 +1,39 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { pool } from '../../../../lib/db/pool.js'; +import { resetDb } from '../../../helpers/db.js'; +import { migrateUp } from '../../../../lib/db/migrate.js'; +import { searchTool } from '../../../../lib/ai/agent/tools/search.js'; +import { readTool } from '../../../../lib/ai/agent/tools/read.js'; + +let spaceId, pageId; +beforeAll(async () => { + await resetDb(); await migrateUp(); + ({ rows: [{ id: spaceId }] } = await pool.query( + `INSERT INTO spaces(slug,name) VALUES('s','S') RETURNING id`)); + // pages requires slug (NOT NULL) and body_md (not body) + ({ rows: [{ id: pageId }] } = await pool.query( + `INSERT INTO pages(space_id,slug,title,body_md) VALUES($1,'telemetry-export','Telemetry export','export CSV via phone') RETURNING id`, + [spaceId])); +}); + +describe('search tool', () => { + it('returns FTS hits scoped to the space', async () => { + const ctx = { space_id: spaceId, actor: { kind: 'user', id: null } }; + const out = await searchTool.handler({ q: 'telemetry' }, ctx); + expect(Array.isArray(out.results)).toBe(true); + // fts() returns title_or_snippet (not title) + expect(out.results.some(r => r.title_or_snippet?.includes('Telemetry'))).toBe(true); + }); +}); + +describe('read tool', () => { + it('fetches a page by id', async () => { + const out = await readTool.handler({ kind: 'page', id: pageId }, { space_id: spaceId }); + expect(out.title).toBe('Telemetry export'); + }); + it('reports not-found cleanly', async () => { + const out = await readTool.handler( + { kind: 'page', id: '00000000-0000-0000-0000-000000000000' }, { space_id: spaceId }); + expect(out.error).toMatch(/not found/i); + }); +});