fix(coding-teacher): restore context and read project files
This commit is contained in:
104
electron/coding-teacher/read-tools.ts
Normal file
104
electron/coding-teacher/read-tools.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
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';
|
||||
|
||||
export interface TeacherReadAccess {
|
||||
projectPath: string;
|
||||
source: TeacherSourceContext;
|
||||
history?: TeacherRequest[];
|
||||
assertCurrent(): void;
|
||||
}
|
||||
|
||||
const lineParameters = {
|
||||
start_line: { type: 'integer', minimum: 1, description: 'First line, default 1.' },
|
||||
line_count: { type: 'integer', minimum: 1, maximum: 100, description: 'Number of lines, default 60.' },
|
||||
};
|
||||
export const teacherReadToolDefinitions = [
|
||||
{ type: 'function', function: {
|
||||
name: 'list_project_files', description: 'List files and directories in the current project. Start with path ".".',
|
||||
parameters: { type: 'object', properties: { path: { type: 'string' }, ...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 numbered lines.',
|
||||
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;
|
||||
}
|
||||
|
||||
function lines(text: string, args: Record<string, unknown>): string {
|
||||
const start = args.start_line ?? 1, count = args.line_count ?? 60;
|
||||
if (typeof start !== 'number' || !Number.isSafeInteger(start) || start < 1
|
||||
|| typeof count !== 'number' || !Number.isSafeInteger(count) || count < 1 || count > 100)
|
||||
throw new Error('Use start_line >= 1 and line_count from 1 to 100.');
|
||||
const all = text.split(/\r?\n/);
|
||||
const selected = all.slice(start - 1, start - 1 + count);
|
||||
return `Lines ${start}-${start + selected.length - 1} of ${all.length}:\n`
|
||||
+ selected.map((line, index) => `${start + index}: ${line}`).join('\n');
|
||||
}
|
||||
|
||||
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 = 2400): Promise<string> {
|
||||
signal.throwIfAborted();
|
||||
access.assertCurrent();
|
||||
let result: string;
|
||||
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 entries = (await files.directory(access.projectPath, projectPath(args.path)))
|
||||
.filter(entry => !['.makelore', '.git'].includes(entry.name.toLowerCase()));
|
||||
result = lines(entries.map(entry => entry.path + (entry.type === 'directory' ? '/' : '')).join('\n') || '(empty directory)', args);
|
||||
break;
|
||||
}
|
||||
case 'read_project_file': {
|
||||
const file = await files.content(access.projectPath, projectPath(args.path));
|
||||
result = file.path + '\n' + lines(file.content, args)
|
||||
+ (file.truncated ? '\n[File exceeds the 256 KiB text preview limit; only its beginning is available.]' : '');
|
||||
break;
|
||||
}
|
||||
case 'read_conversation': {
|
||||
if (args.message_id === undefined) {
|
||||
result = lines(messages.map(message => `${message.id} ${message.role}: ${excerptTeacherText(message.text, 180).replaceAll('\n', ' ')}`).join('\n')
|
||||
|| '(no completed text messages in this conversation)', args);
|
||||
} else {
|
||||
const message = messages.find(message => message.id === args.message_id);
|
||||
if (!message) throw new Error('Message is not in the current conversation.');
|
||||
result = message.id + ' ' + message.role + '\n' + lines(message.text, args);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error('Only list_project_files, read_project_file and read_conversation are available.');
|
||||
}
|
||||
} catch (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();
|
||||
return excerptTeacherText(result, maxBytes);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type TeacherReadTools = ReturnType<typeof createTeacherReadTools>;
|
||||
Reference in New Issue
Block a user