Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@
| `/retry` | Retry a failed or cancelled reply |
| `/regenerate` | Replace the latest assistant reply |
| `/undo` | Remove the latest exchange |
| `/search <text>` | Find saved conversations |
| `/theme <relay\|mono>` | 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.
1 change: 1 addition & 0 deletions src/commands/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <text>', description: 'Find saved conversations.'},
{name: 'theme', usage: '/theme <relay|mono>', description: 'Change the terminal palette.'},
{name: 'stats', usage: '/stats', description: 'Show message and token estimates.'},
{name: 'quit', usage: '/quit', description: 'Close Relay Chat.'},
Expand Down
23 changes: 23 additions & 0 deletions src/commands/execute.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 <text>');
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 <path>.');
store.setNotice(lines.join('\n'));
return;
}
case 'theme': {
const name = chosenPath(command);
if (name === undefined || !chatThemes.has(name))
Expand Down
8 changes: 7 additions & 1 deletion src/persistence/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
3 changes: 3 additions & 0 deletions src/persistence/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);

Expand Down
56 changes: 56 additions & 0 deletions src/persistence/search.ts
Original file line number Diff line number Diff line change
@@ -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<ConversationSearchResult[]> => {
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);
};
65 changes: 65 additions & 0 deletions tests/search.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
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');
});
});
Loading