Skip to content

Commit 3677ee4

Browse files
committed
feat: improve repo matching and exec output cleanup
1 parent 8a6761c commit 3677ee4

6 files changed

Lines changed: 196 additions & 4 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ General:
125125
- `/repo` - list switchable git projects under `WORKSPACE_ROOT`
126126
- `/repo <name>` - switch the current chat to another project
127127
- `/repo <keyword>` - fuzzy match projects; switch if only one match, otherwise list candidates
128+
- `/repo <typo>` - suggests the closest project name when there is no direct match
128129
- `/repo recent` - show recent projects for the current chat
129130
- `/repo -` - switch back to the previous project
130131
- `/new` - clear the saved Codex conversation for the current project and start fresh on the next message
@@ -186,6 +187,7 @@ PTY output is streamed with throttled `editMessageText` updates.
186187
- spoiler (`||...||`, default)
187188
- quote block (if `REASONING_RENDER_MODE=quote`)
188189
- If `node-pty` cannot spawn on the current host, the runner falls back to `codex exec` for per-request execution
190+
- In `codex exec` fallback mode, Telegram output is cleaned to hide the Codex banner, raw tool trace, `mcp startup`, and duplicate `tokens used` footer
189191

190192
## Project-Scoped Conversation State
191193

src/bot/formatter.js

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
const THINK_BLOCK_REGEX = /<think>([\s\S]*?)<\/think>/gi;
22
const TELEGRAM_SPECIAL_REGEX = /[_*[\]()~`>#+\-=|{}.!\\]/g;
3+
const CODEX_DIVIDER = "\n--------\n";
4+
const CODEX_TRANSCRIPT_HEADER_REGEX = /^OpenAI Codex v[^\n]*\n/;
35

46
export function escapeMarkdownV2(input = "") {
57
return String(input).replace(TELEGRAM_SPECIAL_REGEX, "\\$&");
@@ -21,6 +23,101 @@ export function extractReasoning(raw = "") {
2123
};
2224
}
2325

26+
function removeCodexBanner(raw = "") {
27+
const source = String(raw || "").replace(/\r/g, "");
28+
if (!CODEX_TRANSCRIPT_HEADER_REGEX.test(source)) return source;
29+
30+
const firstDividerIndex = source.indexOf(CODEX_DIVIDER);
31+
if (firstDividerIndex === -1) return source;
32+
33+
const secondDividerIndex = source.indexOf(
34+
CODEX_DIVIDER,
35+
firstDividerIndex + CODEX_DIVIDER.length
36+
);
37+
if (secondDividerIndex === -1) return source;
38+
39+
return source.slice(secondDividerIndex + CODEX_DIVIDER.length);
40+
}
41+
42+
export function extractCodexExecResponse(raw = "") {
43+
const source = removeCodexBanner(raw);
44+
if (!source) return "";
45+
46+
const blocks = [];
47+
let section = null;
48+
let currentCodexLines = [];
49+
let skipNextNonEmptyLine = false;
50+
51+
const flushCodexBlock = () => {
52+
if (!currentCodexLines.length) return;
53+
54+
const content = currentCodexLines.join("\n").trim();
55+
if (content) blocks.push(content);
56+
currentCodexLines = [];
57+
};
58+
59+
for (const line of source.split("\n")) {
60+
const trimmed = line.trim();
61+
62+
if (skipNextNonEmptyLine) {
63+
if (trimmed) {
64+
skipNextNonEmptyLine = false;
65+
}
66+
continue;
67+
}
68+
69+
if (trimmed === "tokens used") {
70+
flushCodexBlock();
71+
break;
72+
}
73+
74+
if (/^mcp startup:/i.test(trimmed)) {
75+
continue;
76+
}
77+
78+
if (trimmed === "user") {
79+
flushCodexBlock();
80+
section = "user";
81+
continue;
82+
}
83+
84+
if (trimmed === "codex") {
85+
flushCodexBlock();
86+
section = "codex";
87+
continue;
88+
}
89+
90+
if (trimmed === "exec") {
91+
flushCodexBlock();
92+
section = "exec";
93+
continue;
94+
}
95+
96+
if (trimmed === "tokens" || trimmed === "token usage") {
97+
flushCodexBlock();
98+
break;
99+
}
100+
101+
if (/^[\d,]+$/.test(trimmed) && section !== "codex") {
102+
continue;
103+
}
104+
105+
if (section === "codex") {
106+
currentCodexLines.push(line);
107+
continue;
108+
}
109+
110+
if (/^tokens used\b/i.test(trimmed)) {
111+
flushCodexBlock();
112+
skipNextNonEmptyLine = true;
113+
continue;
114+
}
115+
}
116+
117+
flushCodexBlock();
118+
return blocks.at(-1) || "";
119+
}
120+
24121
function renderReasoningBlock(content, mode = "spoiler") {
25122
const escaped = escapeMarkdownV2(content);
26123
if (mode === "quote") {
@@ -42,8 +139,10 @@ function renderReasoningBlock(content, mode = "spoiler") {
42139
}
43140

44141
export function formatPtyOutput(raw, options = {}) {
45-
const { mode = "spoiler" } = options;
46-
const { cleanText, reasoningBlocks } = extractReasoning(raw);
142+
const { mode = "spoiler", sessionMode = "pty" } = options;
143+
const normalizedRaw =
144+
sessionMode === "exec" ? extractCodexExecResponse(raw) : String(raw || "");
145+
const { cleanText, reasoningBlocks } = extractReasoning(normalizedRaw);
47146
const sections = [];
48147

49148
if (cleanText) {

src/bot/handlers.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { Markup } from "telegraf";
2-
import { buildPlanPrompt, extractCommandPayload } from "./commandUtils.js";
2+
import {
3+
buildPlanPrompt,
4+
extractCommandPayload,
5+
suggestClosestWord
6+
} from "./commandUtils.js";
37
import { escapeMarkdownV2, splitTelegramMessage } from "./formatter.js";
48

59
async function sendChunkedMarkdown(ctx, text, extra = {}) {
@@ -48,6 +52,21 @@ function formatSkillLines(skillStates) {
4852
return skillStates.map((skill) => `- ${skill.name}: ${skill.enabled ? "on" : "off"}`);
4953
}
5054

55+
function suggestProjectName(input, projects) {
56+
const candidates = [
57+
...new Set(
58+
projects.flatMap((project) => [project.relativePath, project.name]).filter(Boolean)
59+
)
60+
];
61+
62+
const threshold = Math.min(
63+
6,
64+
Math.max(2, Math.ceil(String(input || "").trim().length * 0.35))
65+
);
66+
67+
return suggestClosestWord(input, candidates, threshold);
68+
}
69+
5170
export function registerHandlers({
5271
bot,
5372
router,
@@ -209,6 +228,13 @@ export function registerHandlers({
209228
);
210229

211230
if (!matches.length) {
231+
const suggestion = suggestProjectName(payload, projects);
232+
if (suggestion) {
233+
throw new Error(
234+
`没有匹配的项目: ${payload}\n你是不是想找: ${suggestion}\ntry: /repo ${suggestion}`
235+
);
236+
}
237+
212238
throw new Error(`没有匹配的项目: ${payload}`);
213239
}
214240

src/runner/ptyManager.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,10 @@ export class PtyManager {
410410
if (!session) return;
411411

412412
const rawTail = session.rawBuffer.slice(-60000);
413-
const rendered = formatPtyOutput(rawTail, { mode: this.config.reasoning.mode });
413+
const rendered = formatPtyOutput(rawTail, {
414+
mode: this.config.reasoning.mode,
415+
sessionMode: session.mode
416+
});
414417
if (rendered === session.lastRendered) return;
415418
session.lastRendered = rendered;
416419

tests/commandUtils.test.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,10 @@ test("suggestClosestWord returns the nearest supported command when the typo is
2424
assert.equal(suggestClosestWord("ststus", ["list", "status", "tools"]), "status");
2525
assert.equal(suggestClosestWord("zzz", ["list", "status", "tools"]), "");
2626
});
27+
28+
test("suggestClosestWord supports larger edit distances when the caller relaxes the threshold", () => {
29+
assert.equal(
30+
suggestClosestWord("ai-engineer-hub", ["ai-engineering-hub"], 6),
31+
"ai-engineering-hub"
32+
);
33+
});

tests/formatter.test.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import test from "node:test";
22
import assert from "node:assert/strict";
33
import {
44
escapeMarkdownV2,
5+
extractCodexExecResponse,
56
extractReasoning,
67
formatPtyOutput,
78
splitTelegramMessage
@@ -34,6 +35,60 @@ test("formatPtyOutput renders visible output and spoiler reasoning", () => {
3435
assert.match(rendered, /\|\|private reasoning\|\|/);
3536
});
3637

38+
test("extractCodexExecResponse strips codex exec transcript noise and keeps the final assistant reply", () => {
39+
const raw = [
40+
"OpenAI Codex v0.114.0 (research preview)",
41+
"--------",
42+
"workdir: /tmp/demo",
43+
"model: gpt-5.4",
44+
"session id: 11111111-1111-1111-1111-111111111111",
45+
"--------",
46+
"user",
47+
"run unit test",
48+
"mcp startup: no servers",
49+
"codex",
50+
"I’m checking the repository layout first.",
51+
"exec",
52+
"/bin/zsh -lc 'npm test' succeeded in 1.07s:",
53+
"ok",
54+
"codex",
55+
"`npm test` passed.",
56+
"",
57+
"15 tests ran, 15 passed, 0 failed.",
58+
"tokens used",
59+
"8,301",
60+
"`npm test` passed.",
61+
"",
62+
"15 tests ran, 15 passed, 0 failed."
63+
].join("\n");
64+
65+
assert.equal(
66+
extractCodexExecResponse(raw),
67+
"`npm test` passed.\n\n15 tests ran, 15 passed, 0 failed."
68+
);
69+
});
70+
71+
test("formatPtyOutput uses cleaned codex exec content when session mode is exec", () => {
72+
const raw = [
73+
"OpenAI Codex v0.114.0 (research preview)",
74+
"--------",
75+
"workdir: /tmp/demo",
76+
"--------",
77+
"user",
78+
"who are u",
79+
"mcp startup: no servers",
80+
"codex",
81+
"I am Codex."
82+
].join("\n");
83+
84+
const rendered = formatPtyOutput(raw, {
85+
mode: "spoiler",
86+
sessionMode: "exec"
87+
});
88+
89+
assert.equal(rendered, "I am Codex\\.");
90+
});
91+
3792
test("splitTelegramMessage preserves content and avoids trailing escape characters in chunks", () => {
3893
const input = `${"a".repeat(9)}\\b`;
3994
const chunks = splitTelegramMessage(input, 10);

0 commit comments

Comments
 (0)