diff --git a/docs/slash-commands.md b/docs/slash-commands.md index fcf43be..e91c5e7 100644 --- a/docs/slash-commands.md +++ b/docs/slash-commands.md @@ -14,10 +14,13 @@ | `/retry` | Retry a failed or cancelled reply | | `/regenerate` | Replace the latest assistant reply | | `/undo` | Remove the latest exchange | +| `/search ` | Find saved conversations | | `/theme ` | Change the terminal palette | | `/stats` | Show message and token estimates | | `/quit` | Close the interface | `/export` writes Markdown by default. A path ending in `.txt` writes plain text, and a path ending in `.html` writes a standalone web page. +`/search` looks through the JSON files in the `conversations/` directory, the default `/save` location, and lists the best matches with their paths for `/load`. + Paths containing spaces may be wrapped in single or double quotes. diff --git a/src/commands/catalog.ts b/src/commands/catalog.ts index c86832b..8e4a1d5 100644 --- a/src/commands/catalog.ts +++ b/src/commands/catalog.ts @@ -21,6 +21,7 @@ export const commandCatalog: readonly CommandDescription[] = [ {name: 'retry', usage: '/retry', description: 'Retry the last failed or cancelled reply.'}, {name: 'regenerate', usage: '/regenerate', description: 'Replace the latest assistant reply.'}, {name: 'undo', usage: '/undo', description: 'Remove the latest message and its reply.'}, + {name: 'search', usage: '/search ', description: 'Find saved conversations.'}, {name: 'theme', usage: '/theme ', description: 'Change the terminal palette.'}, {name: 'stats', usage: '/stats', description: 'Show message and token estimates.'}, {name: 'quit', usage: '/quit', description: 'Close Relay Chat.'}, diff --git a/src/commands/execute.ts b/src/commands/execute.ts index 06a5184..6e281dd 100644 --- a/src/commands/execute.ts +++ b/src/commands/execute.ts @@ -1,10 +1,12 @@ import {createConversation, estimateConversationTokens} from '../model/index.js'; import { + defaultConversationDirectory, defaultConversationPath, defaultExportPath, exportConversation, loadConversation, saveConversation, + searchConversations, } from '../persistence/index.js'; import type {ChatSession} from '../session/index.js'; import type {ChatStore} from '../store/index.js'; @@ -99,6 +101,27 @@ export const executeSlashCommand = async ( session.undo(); store.setNotice('Removed the latest message and its reply.'); return; + case 'search': { + if (command.rest === '') throw new Error('Usage: /search '); + const directory = defaultConversationDirectory(context.cwd); + const results = await searchConversations(directory, command.rest); + if (results.length === 0) { + store.setNotice(`No saved conversations in ${directory} mention "${command.rest}".`); + return; + } + const plural = results.length === 1 ? '' : 's'; + const lines = [ + `${String(results.length)} saved conversation${plural} mention "${command.rest}":`, + ]; + for (const result of results.slice(0, 5)) { + lines.push(`${result.title} (${String(result.matches)}) · ${result.path}`); + lines.push(` ${result.excerpt}`); + } + if (results.length > 5) lines.push(`…and ${String(results.length - 5)} more`); + lines.push('Open one with /load .'); + store.setNotice(lines.join('\n')); + return; + } case 'theme': { const name = chosenPath(command); if (name === undefined || !chatThemes.has(name)) diff --git a/src/persistence/index.ts b/src/persistence/index.ts index 2d3998b..e6b51e1 100644 --- a/src/persistence/index.ts +++ b/src/persistence/index.ts @@ -4,4 +4,10 @@ export {exportConversation, exportFormatFor} from './export-file.js'; export {conversationToHtml, conversationToText} from './export-text.js'; export {loadConversation, saveConversation} from './file-store.js'; export {parseConversation, serializeConversation} from './json.js'; -export {defaultConversationPath, defaultExportPath, safeFilename} from './paths.js'; +export {searchConversations, type ConversationSearchResult} from './search.js'; +export { + defaultConversationDirectory, + defaultConversationPath, + defaultExportPath, + safeFilename, +} from './paths.js'; diff --git a/src/persistence/paths.ts b/src/persistence/paths.ts index 10fe75a..9b5e079 100644 --- a/src/persistence/paths.ts +++ b/src/persistence/paths.ts @@ -10,6 +10,9 @@ export const safeFilename = (title: string): string => { return normalized.slice(0, 64) || 'conversation'; }; +export const defaultConversationDirectory = (cwd = process.cwd()): string => + resolve(cwd, 'conversations'); + export const defaultConversationPath = (title: string, cwd = process.cwd()): string => resolve(cwd, 'conversations', `${safeFilename(title)}.json`); diff --git a/src/persistence/search.ts b/src/persistence/search.ts new file mode 100644 index 0000000..3a9b1e3 --- /dev/null +++ b/src/persistence/search.ts @@ -0,0 +1,56 @@ +import {readdir} from 'node:fs/promises'; +import {join} from 'node:path'; +import {loadConversation} from './file-store.js'; + +export interface ConversationSearchResult { + path: string; + title: string; + matches: number; + excerpt: string; +} + +const excerptAround = (content: string, index: number, length: number): string => { + const start = Math.max(0, index - 30); + const end = Math.min(content.length, index + length + 30); + const text = content.slice(start, end).replace(/\s+/g, ' ').trim(); + return `${start > 0 ? '…' : ''}${text}${end < content.length ? '…' : ''}`; +}; + +export const searchConversations = async ( + directory: string, + query: string, +): Promise => { + const needle = query.trim().toLowerCase(); + if (needle === '') return []; + let names: string[]; + try { + names = await readdir(directory); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + + const results: ConversationSearchResult[] = []; + for (const name of names.filter((candidate) => candidate.endsWith('.json')).sort()) { + const path = join(directory, name); + let conversation; + try { + conversation = await loadConversation(path); + } catch { + continue; + } + let matches = 0; + let excerpt = ''; + for (const text of [conversation.title, ...conversation.messages.map(({content}) => content)]) { + const lower = text.toLowerCase(); + let index = lower.indexOf(needle); + if (index !== -1 && excerpt === '') excerpt = excerptAround(text, index, needle.length); + while (index !== -1) { + matches += 1; + index = lower.indexOf(needle, index + needle.length); + } + } + if (matches > 0) results.push({path, title: conversation.title, matches, excerpt}); + } + return results.sort((left, right) => right.matches - left.matches); +}; diff --git a/tests/search.test.ts b/tests/search.test.ts new file mode 100644 index 0000000..73e66ae --- /dev/null +++ b/tests/search.test.ts @@ -0,0 +1,65 @@ +import {mkdir, mkdtemp, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {afterEach, describe, expect, it} from 'vitest'; +import {executeSlashCommand} from '../src/commands/index.js'; +import {createConversation, createMessage} from '../src/model/index.js'; +import {saveConversation, searchConversations} from '../src/persistence/index.js'; +import {ChatSession} from '../src/session/index.js'; +import {ChatStore} from '../src/store/index.js'; +import {EchoTransport} from '../src/transport/index.js'; + +const directories: string[] = []; +afterEach(async () => + Promise.all(directories.splice(0).map((path) => rm(path, {recursive: true, force: true}))), +); + +const workspace = async (): Promise => { + const cwd = await mkdtemp(join(tmpdir(), 'relay-chat-')); + directories.push(cwd); + const directory = join(cwd, 'conversations'); + await mkdir(directory); + await saveConversation(join(directory, 'rust.json'), { + ...createConversation({id: 'c1', title: 'Rust lifetimes'}), + messages: [ + createMessage('user', 'Explain borrow checking in Rust.', {id: 'm1'}), + createMessage('assistant', 'The borrow checker tracks references.', {id: 'm2'}), + ], + }); + await saveConversation(join(directory, 'garden.json'), { + ...createConversation({id: 'c2', title: 'Garden plan'}), + messages: [createMessage('user', 'When should I plant tomatoes?', {id: 'm3'})], + }); + await writeFile(join(directory, 'broken.json'), '{not json', 'utf8'); + return cwd; +}; + +describe('saved conversation search', () => { + it('finds case-insensitive matches and skips unreadable files', async () => { + const cwd = await workspace(); + const results = await searchConversations(join(cwd, 'conversations'), 'BORROW'); + expect(results).toEqual([ + { + path: join(cwd, 'conversations', 'rust.json'), + title: 'Rust lifetimes', + matches: 2, + excerpt: 'Explain borrow checking in Rust.', + }, + ]); + expect(await searchConversations(join(cwd, 'missing'), 'borrow')).toEqual([]); + }); + + it('reports matches through /search', async () => { + const cwd = await workspace(); + const store = new ChatStore(); + const session = new ChatSession(store, new EchoTransport({delayMs: 0})); + const context = {store, session, cwd, setHelp: () => {}, setTheme: () => {}, onQuit: () => {}}; + + await executeSlashCommand({name: 'search', arguments: ['tomatoes'], rest: 'tomatoes'}, context); + expect(store.getSnapshot().notice).toContain('1 saved conversation mention "tomatoes"'); + expect(store.getSnapshot().notice).toContain(join(cwd, 'conversations', 'garden.json')); + + await executeSlashCommand({name: 'search', arguments: ['kale'], rest: 'kale'}, context); + expect(store.getSnapshot().notice).toContain('No saved conversations'); + }); +});