Files
makelore/electron/coding-teacher/read-tools.ts

127 lines
7.2 KiB
TypeScript

import path from 'node:path';
import type { TeacherRequest, TeacherSourceContext } from '../../shared/coding-teacher';
import { CodingProjectFileService } from '../coding-projects/project-files';
import { excerptTeacherText, teacherHistoryMessages } from './context';
import { readInteger, readPage, textChunks } from './read-page';
export const TEACHER_READ_PROTOCOL = 2;
export const TEACHER_READ_ROUNDS = 12;
export const TEACHER_READ_BYTES = 64 * 1024;
export const TEACHER_READ_PAGE_BYTES = 8 * 1024;
export const TEACHER_READ_MIN_BYTES = 512;
export interface TeacherReadAccess {
projectPath: string;
source: TeacherSourceContext;
history?: TeacherRequest[];
assertCurrent(): void;
}
const lineParameters = {
start_line: { type: 'integer', minimum: 1, description: 'First line, default 1.' },
start_column: { type: 'integer', minimum: 1, description: 'Unicode character column, default 1. Copy next from the previous page to continue.' },
line_count: { type: 'integer', minimum: 1, maximum: 400, description: 'Maximum lines, default 120. Output is bounded; use next for the exact continuation.' },
};
export const teacherReadToolDefinitions = [
{ type: 'function', function: {
name: 'list_project_files', description: 'List a bounded project tree. Start with path "."; use relevant subdirectories for deeper exploration. Files unchanged during a page sequence have stable line order.',
parameters: { type: 'object', properties: { path: { type: 'string' }, depth: { type: 'integer', minimum: 1, maximum: 6, description: 'Tree depth, default 4. Directories at the depth boundary can be listed separately.' }, ...lineParameters }, required: ['path'], additionalProperties: false },
} },
{ type: 'function', function: {
name: 'read_project_file', description: 'Read UTF-8 source from a current-project relative path, including .makelore/project.json metadata. Read only; no commands or edits.',
parameters: { type: 'object', properties: { path: { type: 'string' }, ...lineParameters }, required: ['path'], additionalProperties: false },
} },
{ type: 'function', function: {
name: 'read_conversation', description: 'Read the captured active coding conversation and current teacher topic. Omit message_id to list messages; supply it to read continuous original text. Results give start_line/start_column and next; do not reread unchanged ranges.',
parameters: { type: 'object', properties: { message_id: { type: 'string' }, ...lineParameters }, additionalProperties: false },
} },
];
function projectPath(value: unknown): string {
if (typeof value !== 'string' || !value.trim()) throw new Error('A relative project path is required.');
const normalized = path.posix.normalize(value.trim().replaceAll('\\', '/'));
if (normalized.toLowerCase() !== '.makelore/project.json'
&& normalized.split('/').some(part => ['.makelore', '.git'].includes(part.toLowerCase()))) {
throw new Error('Application history and Git internals are not project source files.');
}
return normalized;
}
export function createTeacherReadTools(access: TeacherReadAccess) {
const files = new CodingProjectFileService();
const messages = [...access.source.messages, ...teacherHistoryMessages(access.history ?? [])];
return {
definitions: teacherReadToolDefinitions,
async execute(name: string, rawArguments: string, signal: AbortSignal, maxBytes = TEACHER_READ_PAGE_BYTES): Promise<string> {
return (await this.executeResult(name, rawArguments, signal, maxBytes)).content;
},
async executeResult(name: string, rawArguments: string, signal: AbortSignal, maxBytes = TEACHER_READ_PAGE_BYTES): Promise<{ status: 'success' | 'error'; content: string; truncated: boolean }> {
signal.throwIfAborted();
access.assertCurrent();
let result: string;
let status: 'success' | 'error' = 'success';
let truncated = false;
try {
const args = JSON.parse(rawArguments) as Record<string, unknown>;
if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('Expected an object.');
switch (name) {
case 'list_project_files': {
const root = projectPath(args.path);
const depth = readInteger(args.depth, 4, 6);
async function* tree(): AsyncGenerator<string> {
const directories = [{ path: root, level: 1 }];
let found = false;
for (let i = 0; i < directories.length; i++) {
signal.throwIfAborted();
access.assertCurrent();
const directory = directories[i];
for (const entry of await files.directory(access.projectPath, directory.path)) {
if (['.makelore', '.git', '.gradle'].includes(entry.name.toLowerCase())) continue;
found = true;
yield entry.path + (entry.type === 'directory' ? '/' : '') + '\n';
if (entry.type === 'directory' && directory.level < depth)
directories.push({ path: entry.path, level: directory.level + 1 });
}
}
if (!found) yield '(empty directory)';
}
({ content: result, truncated } = await readPage(root + ' (tree depth ' + depth + ')',
tree(), args, maxBytes));
break;
}
case 'read_project_file': {
const target = projectPath(args.path);
({ content: result, truncated } = await readPage(target,
files.textChunks(access.projectPath, target, signal), args, maxBytes));
break;
}
case 'read_conversation': {
const message = args.message_id === undefined ? undefined : messages.find(item => item.id === args.message_id);
if (args.message_id !== undefined && !message) throw new Error('Message is not in the current conversation.');
const text = message?.text ?? (messages.map(item =>
`${item.id} ${item.role}: ${excerptTeacherText(item.text, 180).replaceAll('\n', ' ')}`).join('\n')
|| '(no completed text messages in this conversation)');
({ content: result, truncated } = await readPage(message ? message.id + ' ' + message.role : 'conversation message index (snippets)',
textChunks(text), args, maxBytes));
break;
}
default:
throw new Error('Only list_project_files, read_project_file and read_conversation are available.');
}
} catch (error) {
status = 'error';
// Keep local OS paths and unrelated application data out of model errors.
result = 'Read failed. Check the relative path, message id and line range. '
+ (error instanceof SyntaxError ? 'Tool arguments must be valid JSON.' : 'Only current-project text files and current-conversation messages are available.');
}
signal.throwIfAborted();
access.assertCurrent();
// Only the generic error can need truncation; source pages already fit exactly.
const content = status === 'error' ? excerptTeacherText(result, maxBytes) : result;
return { status, content, truncated: truncated || content !== result };
},
};
}
export type TeacherReadTools = ReturnType<typeof createTeacherReadTools>;