130 lines
7.7 KiB
TypeScript
130 lines
7.7 KiB
TypeScript
import path from 'node:path';
|
|
import type { TeacherRequest, TeacherSourceContext, TeacherSourceMessage } 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[];
|
|
archive?: { messages: TeacherSourceMessage[]; read(id: string): Promise<TeacherSourceMessage | undefined> };
|
|
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 this agent chat history. Historical messages can belong to earlier projects and are labelled as such; files remain limited to the current project. 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 ?? [])];
|
|
const index = [...access.source.messages, ...(access.archive?.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)
|
|
?? (typeof args.message_id === 'string' ? await access.archive?.read(args.message_id) : undefined);
|
|
if (args.message_id !== undefined && !message) throw new Error('Message is not in the current conversation.');
|
|
const text = message?.text ?? (index.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>;
|