From cbcd23597f7fb27377e6459d67011a3edd8b060c Mon Sep 17 00:00:00 2001 From: Rin Date: Sun, 8 Feb 2026 02:14:42 +0800 Subject: [PATCH] fix(minecraft): semicolon now works in single line Extract normalizeReplCode/rewriteTrailingExpressionToReturn from Brain to new repl-code-normalizer module, add test verifying single-line statements with trailing expressions return values (const nearestLog = [...]; nearestLog) --- .../src/cognitive/conscious/brain.test.ts | 9 ++ .../src/cognitive/conscious/brain.ts | 42 +-------- .../conscious/repl-code-normalizer.test.ts | 41 +++++++++ .../conscious/repl-code-normalizer.ts | 91 +++++++++++++++++++ 4 files changed, 143 insertions(+), 40 deletions(-) create mode 100644 services/minecraft/src/cognitive/conscious/repl-code-normalizer.test.ts create mode 100644 services/minecraft/src/cognitive/conscious/repl-code-normalizer.ts diff --git a/services/minecraft/src/cognitive/conscious/brain.test.ts b/services/minecraft/src/cognitive/conscious/brain.test.ts index 92c067013..9ff22f3f9 100644 --- a/services/minecraft/src/cognitive/conscious/brain.test.ts +++ b/services/minecraft/src/cognitive/conscious/brain.test.ts @@ -131,6 +131,15 @@ inv; expect(result.returnValue).toContain('oak_sapling') }) + it('returns trailing expression values from single-line statements', async () => { + const brain: any = new Brain(createDeps('await skip()')) + + const result = await brain.executeDebugRepl('const nearestLog = [{ name: "oak_log" }]; nearestLog') + + expect(result.error).toBeUndefined() + expect(result.returnValue).toContain('oak_log') + }) + it('queues exactly one synthetic follow-up on no-action result', async () => { const brain: any = new Brain(createDeps('1 + 1')) const enqueueSpy = vi.fn(async () => undefined) diff --git a/services/minecraft/src/cognitive/conscious/brain.ts b/services/minecraft/src/cognitive/conscious/brain.ts index 33c694cd2..8f1fd09bd 100644 --- a/services/minecraft/src/cognitive/conscious/brain.ts +++ b/services/minecraft/src/cognitive/conscious/brain.ts @@ -26,6 +26,7 @@ import { toErrorMessage, } from './llmlogic' import { generateBrainSystemPrompt } from './prompts/brain-prompt' +import { normalizeReplScript } from './repl-code-normalizer' import { createCancellationToken } from './task-state' interface BrainDeps { @@ -507,46 +508,7 @@ export class Brain { } private normalizeReplCode(code: string): string { - // Simple top-level var persistence for REPL UX: - // const/let/var foo = ... -> globalThis.foo = ... - const normalized = code.replace( - /^\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^\n]+?)\s*(?:;\s*)?$/gm, - (_full: string, name: string, valueExpr: string) => `globalThis.${name} = ${valueExpr}`, - ) - return this.rewriteTrailingExpressionToReturn(normalized) - } - - private rewriteTrailingExpressionToReturn(code: string): string { - if (/\breturn\b/.test(code)) - return code - - const lines = code.split('\n') - let lastIndex = -1 - for (let i = lines.length - 1; i >= 0; i--) { - if (lines[i].trim().length > 0) { - lastIndex = i - break - } - } - - if (lastIndex < 0) - return code - - const rawLine = lines[lastIndex] - const trimmedLine = rawLine.trim() - if (trimmedLine.length === 0 || trimmedLine.startsWith('//')) - return code - if (trimmedLine === '}' || trimmedLine.endsWith('{')) - return code - - const expression = trimmedLine.replace(/;$/, '').trim() - const blocked = /^(?:if|for|while|switch|const|let|var|function|class|try|catch|finally|return|throw|import|export)\b/ - if (blocked.test(expression)) - return code - - const indent = rawLine.match(/^\s*/)?.[0] ?? '' - lines[lastIndex] = `${indent}return (${expression})` - return lines.join('\n') + return normalizeReplScript(code) } private toDebugReplActions(actions: Array<{ diff --git a/services/minecraft/src/cognitive/conscious/repl-code-normalizer.test.ts b/services/minecraft/src/cognitive/conscious/repl-code-normalizer.test.ts new file mode 100644 index 000000000..b8718fe23 --- /dev/null +++ b/services/minecraft/src/cognitive/conscious/repl-code-normalizer.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' + +import { normalizeReplScript } from './repl-code-normalizer' + +describe('normalizeReplScript', () => { + it('rewrites top-level variable declarations and trailing expression', () => { + const normalized = normalizeReplScript('const nearestLog = query.blocks().first(); nearestLog') + + expect(normalized).toContain('globalThis.nearestLog = query.blocks().first();') + expect(normalized).toContain('return (nearestLog)') + expect(normalized).not.toContain('return (globalThis.nearestLog = query.blocks().first(); nearestLog)') + }) + + it('does not rewrite trailing expression when explicit return exists', () => { + const normalized = normalizeReplScript(` +const inv = [{ name: 'oak_log' }] +return inv +`) + + expect(normalized).toContain('globalThis.inv = [{ name: \'oak_log\' }];') + expect(normalized).toContain('return inv') + expect(normalized).not.toContain('return (inv)') + }) + + it('leaves scripts unchanged when parser reports diagnostics', () => { + const script = 'const broken = ;' + expect(normalizeReplScript(script)).toBe(script) + }) + + it('keeps non-expression trailing statements without implicit return', () => { + const normalized = normalizeReplScript(` +const x = 1 +if (x > 0) { + x +} +`) + + expect(normalized).toContain('globalThis.x = 1;') + expect(normalized).not.toContain('return (') + }) +}) diff --git a/services/minecraft/src/cognitive/conscious/repl-code-normalizer.ts b/services/minecraft/src/cognitive/conscious/repl-code-normalizer.ts new file mode 100644 index 000000000..3a64e23fb --- /dev/null +++ b/services/minecraft/src/cognitive/conscious/repl-code-normalizer.ts @@ -0,0 +1,91 @@ +import ts from 'typescript' + +interface TextReplacement { + start: number + end: number + value: string +} + +function getNodeText(sourceFile: ts.SourceFile, node: ts.Node): string { + const start = node.getStart(sourceFile, false) + const end = node.getEnd() + return sourceFile.text.slice(start, end) +} + +function getVariableKeyword(flags: ts.NodeFlags): 'const' | 'let' | 'var' { + if (flags & ts.NodeFlags.Const) + return 'const' + if (flags & ts.NodeFlags.Let) + return 'let' + return 'var' +} + +function normalizeVariableStatement(sourceFile: ts.SourceFile, statement: ts.VariableStatement): string { + const keyword = getVariableKeyword(statement.declarationList.flags) + const fragments: string[] = [] + + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name)) { + const initializer = declaration.initializer ? getNodeText(sourceFile, declaration.initializer) : 'undefined' + fragments.push(`globalThis.${declaration.name.text} = ${initializer};`) + continue + } + + const declarationText = getNodeText(sourceFile, declaration) + fragments.push(`${keyword} ${declarationText};`) + } + + return fragments.join('\n') +} + +function applyReplacements(code: string, replacements: TextReplacement[]): string { + if (replacements.length === 0) + return code + + let output = code + const sorted = [...replacements].sort((a, b) => b.start - a.start) + for (const replacement of sorted) { + output = `${output.slice(0, replacement.start)}${replacement.value}${output.slice(replacement.end)}` + } + return output +} + +export function normalizeReplScript(code: string): string { + const sourceFile = ts.createSourceFile('repl.ts', code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) + const diagnostics = (sourceFile as ts.SourceFile & { + parseDiagnostics?: readonly ts.DiagnosticWithLocation[] + }).parseDiagnostics + + if (diagnostics && diagnostics.length > 0) + return code + + const replacements: TextReplacement[] = [] + + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) + continue + + const start = statement.getStart(sourceFile, false) + const end = statement.getEnd() + replacements.push({ + start, + end, + value: normalizeVariableStatement(sourceFile, statement), + }) + } + + const hasTopLevelReturn = sourceFile.statements.some(statement => ts.isReturnStatement(statement)) + if (!hasTopLevelReturn && sourceFile.statements.length > 0) { + const lastStatement = sourceFile.statements[sourceFile.statements.length - 1] + if (ts.isExpressionStatement(lastStatement)) { + const expressionText = getNodeText(sourceFile, lastStatement.expression) + replacements.push({ + start: lastStatement.getStart(sourceFile, false), + end: lastStatement.getEnd(), + value: `return (${expressionText})`, + }) + } + } + + return applyReplacements(code, replacements) +}