Skip to content

Commit a6d3fca

Browse files
khaliqgantclaude
andcommitted
fix(github-app): proper UTF-8 encode/decode on file round-trip
atob() returns Latin-1-interpreted bytes; the classic btoa(unescape(encodeURIComponent(s))) trick relies on deprecated APIs and was producing mangled multi-byte chars (em-dashes, smart quotes) when the bot rewrote agent-log.json. Use TextEncoder / TextDecoder for both directions. Also restore the em-dashes in the seed log entries that got eaten by the first round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8e776bf commit a6d3fca

2 files changed

Lines changed: 19 additions & 6 deletions

File tree

agents/shared/github-app.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,13 @@ export async function readRepoJson<T = unknown>(
5353
ref: args.ref,
5454
});
5555
if (Array.isArray(res.data) || res.data.type !== "file") return null;
56-
const text = atob(res.data.content.replace(/\n/g, ""));
56+
// Properly decode UTF-8: atob() returns Latin-1-interpreted bytes,
57+
// which mangles multi-byte chars (em-dash, smart quotes, etc.) on
58+
// round-trip. TextDecoder is the correct primitive.
59+
const bytes = Uint8Array.from(atob(res.data.content.replace(/\n/g, "")), (c) =>
60+
c.charCodeAt(0),
61+
);
62+
const text = new TextDecoder("utf-8").decode(bytes);
5763
return { data: JSON.parse(text) as T, sha: res.data.sha };
5864
} catch (err) {
5965
const status = (err as { status?: number }).status;
@@ -84,7 +90,14 @@ export async function writeRepoJson(
8490
path: args.path,
8591
ref: args.branch,
8692
});
87-
const content = btoa(unescape(encodeURIComponent(JSON.stringify(args.data, null, 2) + "\n")));
93+
// Encode UTF-8 → bytes → base64. The classic
94+
// `btoa(unescape(encodeURIComponent(s)))` trick relies on deprecated APIs;
95+
// TextEncoder is the modern equivalent.
96+
const json = JSON.stringify(args.data, null, 2) + "\n";
97+
const bytes = new TextEncoder().encode(json);
98+
let binary = "";
99+
for (const b of bytes) binary += String.fromCharCode(b);
100+
const content = btoa(binary);
88101

89102
await octokit.rest.repos.createOrUpdateFileContents({
90103
owner: args.owner,

content/agent-log.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
"agent": "manual-chatbot",
4646
"trigger": "message",
4747
"action": "Answered a Slack DM",
48-
"summary": "Question: 'what's the difference between proactive and event-driven?' — answered with a two-paragraph distinction citing the manifesto, flagged the framing of 'event-driven' as ambiguous between push-architecture and pub-sub.",
48+
"summary": "Question: 'what's the difference between proactive and event-driven?' €” answered with a two-paragraph distinction citing the manifesto, flagged the framing of 'event-driven' as ambiguous between push-architecture and pub-sub.",
4949
"outcome": "success",
5050
"links": [
5151
{
@@ -74,7 +74,7 @@
7474
"timestamp": "2026-05-07T11:25:00Z",
7575
"agent": "notion-to-blog",
7676
"trigger": "change",
77-
"action": "Skipped — page still draft",
77+
"action": "Skipped €” page still draft",
7878
"summary": "Page 'Why context is the agent's whole job' moved into Editing in Notion, but tag is still 'draft'. Nothing published. Will re-evaluate when status flips to 'ready'.",
7979
"outcome": "skipped",
8080
"skippedReason": "tag != ready",
@@ -91,7 +91,7 @@
9191
"agent": "notion-to-blog",
9292
"trigger": "change",
9393
"action": "Published essay",
94-
"summary": "New page in Notion 'Drafts → Ready' database tagged ready. Converted to MDX, applied the site's Scene + Sidenote conventions, opened a PR with the post.",
94+
"summary": "New page in Notion 'Drafts †’ Ready' database tagged ready. Converted to MDX, applied the site's Scene + Sidenote conventions, opened a PR with the post.",
9595
"outcome": "success",
9696
"links": [
9797
{
@@ -110,7 +110,7 @@
110110
"agent": "weekly-digest",
111111
"trigger": "time",
112112
"action": "Filed weekly digest",
113-
"summary": "Quiet week — only 4 mentions, no new clusters. Filed a short digest with a note that we may want to drop frequency to bi-weekly if signal stays low.",
113+
"summary": "Quiet week €” only 4 mentions, no new clusters. Filed a short digest with a note that we may want to drop frequency to bi-weekly if signal stays low.",
114114
"outcome": "success",
115115
"links": [
116116
{

0 commit comments

Comments
 (0)