changebook 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ChangeBook for coding agents — MCP server (stdio) + CLI.
4
+ *
5
+ * MCP mode lets agents (Claude Code, Codex, Cursor…) query the ChangeBook
6
+ * "product memory" — the module map and analyzed change history stored in
7
+ * Supabase — instead of re-reading the codebase. All tools are read-only.
8
+ * The CLI subcommands feed and connect that memory without the VS Code
9
+ * extension: login, analyze, sync, init, open.
10
+ */
11
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
+ import { analyze } from "./analyze.js";
14
+ import { atlasWebUrl, openInBrowser } from "./browser.js";
15
+ import { clearCredentials, credentialsPath } from "./credentials.js";
16
+ import { hookStatus, installHook, uninstallHook } from "./hook.js";
17
+ import { importHistory } from "./import.js";
18
+ import { registerAgents } from "./init.js";
19
+ import { login } from "./login.js";
20
+ import { AUTH_HELP, Supabase } from "./supabase.js";
21
+ import { syncContextFiles } from "./sync.js";
22
+ import { registerTools } from "./tools.js";
23
+ const HELP = `changebook — the ChangeBook product memory, from any terminal
24
+
25
+ Usage:
26
+ changebook login Sign in via the browser (stores ~/.changebook/credentials.json)
27
+ changebook logout Forget the stored session
28
+ changebook analyze [dir] Analyze uncommitted changes and update the atlas
29
+ changebook analyze --commit [ref] Analyze one commit (deduped; used by the git hook)
30
+ changebook import [dir] [--commits N]
31
+ Backfill the last N commits (default 25) via the
32
+ Anthropic Batch API — 50% cheaper, non-interactive
33
+ changebook hook install|uninstall|status [dir]
34
+ Git post-commit hook: analyze every new commit automatically
35
+ changebook sync [dir] Refresh the product map inside CLAUDE.md/AGENTS.md
36
+ changebook init [dir] login + register MCP (Claude Code, Codex) + hook + sync
37
+ changebook open Open the web atlas in the browser
38
+ changebook serve Run the MCP server on stdio (default with no arguments)
39
+
40
+ Environment: CHANGEBOOK_REFRESH_TOKEN / CHANGEBOOK_ACCESS_TOKEN override the stored
41
+ session (CI/headless); CHANGEBOOK_PROJECT scopes queries to one project.`;
42
+ function requireCredentials(db) {
43
+ if (!db.hasCredentials()) {
44
+ console.error(AUTH_HELP);
45
+ process.exit(1);
46
+ }
47
+ }
48
+ async function serve(db) {
49
+ const server = new McpServer({
50
+ name: "changebook-mcp-server",
51
+ version: "0.2.0",
52
+ });
53
+ registerTools(server, db);
54
+ if (!db.hasCredentials()) {
55
+ // Don't exit: keep serving so the agent gets the actionable message from
56
+ // the tools themselves, but make the problem visible in the client logs.
57
+ console.error(AUTH_HELP);
58
+ }
59
+ const transport = new StdioServerTransport();
60
+ await server.connect(transport);
61
+ console.error("changebook-mcp-server running on stdio");
62
+ }
63
+ /** `[dir] [--commit [ref]]` in any order → analyze options. */
64
+ function parseAnalyzeArgs(args) {
65
+ let dir;
66
+ let commit;
67
+ for (let i = 0; i < args.length; i++) {
68
+ if (args[i] === "--commit") {
69
+ commit = args[i + 1] && !args[i + 1].startsWith("--") ? args[++i] : "HEAD";
70
+ }
71
+ else if (!args[i].startsWith("--")) {
72
+ dir = args[i];
73
+ }
74
+ }
75
+ return { dir, commit };
76
+ }
77
+ /** `[dir] [--commits N]` in any order → import options. */
78
+ function parseImportArgs(args) {
79
+ let dir;
80
+ let commits;
81
+ for (let i = 0; i < args.length; i++) {
82
+ if (args[i] === "--commits") {
83
+ const raw = args[i + 1];
84
+ const n = Number(raw);
85
+ if (!Number.isInteger(n) || n <= 0) {
86
+ throw new Error(`--commits needs a positive integer (got "${raw ?? ""}").`);
87
+ }
88
+ commits = n;
89
+ i++;
90
+ }
91
+ else if (!args[i].startsWith("--")) {
92
+ dir = args[i];
93
+ }
94
+ }
95
+ return { dir, commits };
96
+ }
97
+ async function main() {
98
+ const [command, arg] = process.argv.slice(2);
99
+ switch (command) {
100
+ case undefined:
101
+ case "serve":
102
+ await serve(new Supabase());
103
+ return;
104
+ case "login":
105
+ await login();
106
+ return;
107
+ case "logout":
108
+ console.error(clearCredentials()
109
+ ? `✓ Session removed from ${credentialsPath()}`
110
+ : "No stored session.");
111
+ return;
112
+ case "analyze": {
113
+ const db = new Supabase();
114
+ requireCredentials(db);
115
+ await analyze(db, parseAnalyzeArgs(process.argv.slice(3)));
116
+ return;
117
+ }
118
+ case "import": {
119
+ const db = new Supabase();
120
+ requireCredentials(db);
121
+ await importHistory(db, parseImportArgs(process.argv.slice(3)));
122
+ return;
123
+ }
124
+ case "hook": {
125
+ const dir = process.argv[4] ?? process.cwd();
126
+ if (arg === "install")
127
+ await installHook(dir);
128
+ else if (arg === "uninstall")
129
+ await uninstallHook(dir);
130
+ else
131
+ await hookStatus(dir);
132
+ return;
133
+ }
134
+ case "sync": {
135
+ const db = new Supabase();
136
+ requireCredentials(db);
137
+ await syncContextFiles(db, arg ?? process.cwd());
138
+ return;
139
+ }
140
+ case "init": {
141
+ let db = new Supabase();
142
+ if (!db.hasCredentials()) {
143
+ await login();
144
+ db = new Supabase(); // pick up the freshly stored credentials
145
+ }
146
+ for (const line of await registerAgents())
147
+ console.error(line);
148
+ const dir = arg ?? process.cwd();
149
+ // The living-atlas part: analyze every new commit automatically. Not a
150
+ // git repo (or a foreign hook already there) → explain and move on.
151
+ try {
152
+ await installHook(dir);
153
+ }
154
+ catch (error) {
155
+ console.error(error instanceof Error ? error.message : String(error));
156
+ }
157
+ await syncContextFiles(db, dir);
158
+ console.error(`✓ Ready. Ask your agent about the atlas, or open ${atlasWebUrl()}`);
159
+ return;
160
+ }
161
+ case "open": {
162
+ const url = atlasWebUrl();
163
+ console.error(url);
164
+ openInBrowser(url);
165
+ return;
166
+ }
167
+ case "help":
168
+ case "--help":
169
+ case "-h":
170
+ console.error(HELP);
171
+ return;
172
+ default:
173
+ console.error(`Unknown command "${command}".\n\n${HELP}`);
174
+ process.exit(1);
175
+ }
176
+ }
177
+ main().catch((error) => {
178
+ console.error(error instanceof Error ? error.message : String(error));
179
+ process.exit(1);
180
+ });
181
+ //# sourceMappingURL=index.js.map
package/dist/init.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * `changebook init [dir]` — one-command setup for coding agents:
3
+ * 1. Sign in if needed (browser flow).
4
+ * 2. Register the MCP server in Claude Code (user scope) and Codex.
5
+ * 3. Sync the product map into the project's CLAUDE.md / AGENTS.md.
6
+ */
7
+ import * as fs from "node:fs";
8
+ import * as os from "node:os";
9
+ import * as path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { execFileAsync } from "./git.js";
12
+ /** Absolute invocation of this package's server entry, install-agnostic. */
13
+ function serverCommand() {
14
+ const entry = path.join(path.dirname(fileURLToPath(import.meta.url)), "index.js");
15
+ return { command: process.execPath, args: [entry, "serve"] };
16
+ }
17
+ export async function registerAgents() {
18
+ const done = [];
19
+ const { command, args } = serverCommand();
20
+ // On Windows `claude` is a .cmd shim: execFile can't launch it without a
21
+ // shell (Node throws EINVAL since CVE-2024-27980). Run through the shell there
22
+ // and quote the path arguments so spaces (e.g. "Program Files") survive; the
23
+ // rest are constant literals, so there's no injection surface.
24
+ const isWin = process.platform === "win32";
25
+ const quote = (s) => (isWin ? `"${s}"` : s);
26
+ // Claude Code: user scope, so every project sees the atlas tools.
27
+ try {
28
+ await execFileAsync("claude", [
29
+ "mcp",
30
+ "add",
31
+ "--scope",
32
+ "user",
33
+ "changebook",
34
+ "--",
35
+ quote(command),
36
+ ...args.map(quote),
37
+ ], isWin ? { shell: true } : {});
38
+ done.push("Claude Code: MCP server registered (user scope).");
39
+ }
40
+ catch (error) {
41
+ const msg = error instanceof Error ? error.message : String(error);
42
+ if (/already exists/i.test(msg)) {
43
+ done.push("Claude Code: already configured.");
44
+ }
45
+ else if (/ENOENT|not recognized|command not found/i.test(msg)) {
46
+ done.push("Claude Code: `claude` CLI not found — install it and run `changebook init` again.");
47
+ }
48
+ else {
49
+ done.push(`Claude Code: could not register (${msg.split("\n")[0]}).`);
50
+ }
51
+ }
52
+ // Codex reads ~/.codex/config.toml; append our server if it's not there.
53
+ const codexDir = path.join(os.homedir(), ".codex");
54
+ const codexConfig = path.join(codexDir, "config.toml");
55
+ if (fs.existsSync(codexDir)) {
56
+ const current = fs.existsSync(codexConfig)
57
+ ? fs.readFileSync(codexConfig, "utf8")
58
+ : "";
59
+ if (current.includes("[mcp_servers.changebook]")) {
60
+ done.push("Codex: already configured.");
61
+ }
62
+ else {
63
+ const argsToml = args.map((a) => JSON.stringify(a)).join(", ");
64
+ const block = `\n[mcp_servers.changebook]\n` +
65
+ `command = ${JSON.stringify(command)}\n` +
66
+ `args = [${argsToml}]\n`;
67
+ fs.appendFileSync(codexConfig, block);
68
+ done.push("Codex: MCP server added to ~/.codex/config.toml.");
69
+ }
70
+ }
71
+ else {
72
+ done.push("Codex: ~/.codex not found — skipped.");
73
+ }
74
+ return done;
75
+ }
76
+ //# sourceMappingURL=init.js.map
package/dist/login.js ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * `changebook login` — browser-based sign-in, no token copy-pasting.
3
+ *
4
+ * Flow (loopback, same trust model as the web→editor handoff):
5
+ * 1. Start an HTTP server on 127.0.0.1 with an ephemeral port.
6
+ * 2. Open https://changebook.app/?connect=cli&port=<p>&state=<s>. The web
7
+ * app (where the user is signed in) shows an explicit Authorize button.
8
+ * 3. On click it redirects to http://127.0.0.1:<p>/callback with the
9
+ * session tokens in the URL fragment — the fragment never leaves the
10
+ * machine; a tiny page reads it and POSTs it to this server.
11
+ * 4. We verify `state`, store the refresh token in ~/.changebook/ and exit.
12
+ */
13
+ import { randomBytes } from "node:crypto";
14
+ import * as http from "node:http";
15
+ import { atlasWebUrl, openInBrowser } from "./browser.js";
16
+ import { credentialsPath, saveCredentials } from "./credentials.js";
17
+ const LOGIN_TIMEOUT_MS = 5 * 60_000;
18
+ const CALLBACK_PAGE = `<!DOCTYPE html>
19
+ <html><head><meta charset="utf-8"><title>ChangeBook</title></head>
20
+ <body style="font-family: sans-serif; max-width: 480px; margin: 80px auto; text-align: center;">
21
+ <p id="msg">Conectando…</p>
22
+ <script>
23
+ const params = new URLSearchParams(window.location.hash.slice(1));
24
+ const payload = {
25
+ access_token: params.get("access_token"),
26
+ refresh_token: params.get("refresh_token"),
27
+ state: params.get("state"),
28
+ };
29
+ history.replaceState(null, "", "/callback"); // drop tokens from the URL bar
30
+ fetch("/token", {
31
+ method: "POST",
32
+ headers: { "Content-Type": "application/json" },
33
+ body: JSON.stringify(payload),
34
+ }).then((r) => {
35
+ document.getElementById("msg").textContent = r.ok
36
+ ? "✓ Sesión conectada. Ya puedes volver al terminal."
37
+ : "No se pudo completar el login. Vuelve al terminal e inténtalo de nuevo.";
38
+ });
39
+ </script>
40
+ </body></html>`;
41
+ export async function login() {
42
+ const state = randomBytes(16).toString("hex");
43
+ const tokens = await new Promise((resolve, reject) => {
44
+ const server = http.createServer((req, res) => {
45
+ if (req.method === "GET" && req.url?.startsWith("/callback")) {
46
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
47
+ res.end(CALLBACK_PAGE);
48
+ return;
49
+ }
50
+ if (req.method === "POST" && req.url === "/token") {
51
+ let raw = "";
52
+ req.on("data", (chunk) => {
53
+ raw += chunk;
54
+ if (raw.length > 16_384)
55
+ req.destroy();
56
+ });
57
+ req.on("end", () => {
58
+ try {
59
+ const data = JSON.parse(raw);
60
+ if (data.state !== state) {
61
+ res.writeHead(400);
62
+ res.end();
63
+ return;
64
+ }
65
+ if (!data.refresh_token) {
66
+ res.writeHead(400);
67
+ res.end();
68
+ return;
69
+ }
70
+ res.writeHead(200, { "Content-Type": "application/json" });
71
+ res.end('{"ok":true}');
72
+ server.close();
73
+ resolve({
74
+ access_token: data.access_token ?? undefined,
75
+ refresh_token: data.refresh_token,
76
+ });
77
+ }
78
+ catch {
79
+ res.writeHead(400);
80
+ res.end();
81
+ }
82
+ });
83
+ return;
84
+ }
85
+ res.writeHead(404);
86
+ res.end();
87
+ });
88
+ server.on("error", reject);
89
+ // Loopback only: nothing outside this machine can reach the server.
90
+ server.listen(0, "127.0.0.1", () => {
91
+ const address = server.address();
92
+ if (!address || typeof address === "string") {
93
+ reject(new Error("Could not open a loopback port."));
94
+ return;
95
+ }
96
+ const url = `${atlasWebUrl()}/?connect=cli&port=${address.port}&state=${state}`;
97
+ console.error("Opening your browser to sign in to ChangeBook…");
98
+ console.error(`If it doesn't open, visit:\n\n ${url}\n`);
99
+ openInBrowser(url);
100
+ });
101
+ setTimeout(() => {
102
+ server.close();
103
+ reject(new Error("Login timed out after 5 minutes. Run `changebook login` again."));
104
+ }, LOGIN_TIMEOUT_MS).unref();
105
+ });
106
+ saveCredentials(tokens);
107
+ console.error(`✓ Signed in. Session stored in ${credentialsPath()}`);
108
+ }
109
+ //# sourceMappingURL=login.js.map
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Diff compression before sending to the backend — same rules as the VS Code
3
+ * extension's src/util/optimize.ts (keep both in sync): one block per file
4
+ * with a stats header, generated files stubbed, comments and blank lines
5
+ * stripped to cut tokens without losing the actual change.
6
+ */
7
+ // Per-file budget instead of a global one: without it a single huge file eats
8
+ // the server's 60k cap and crowds out the rest of the commit.
9
+ const PER_FILE_BUDGET_CHARS = 6_000;
10
+ // Generated files are noise for the model (a package-lock.json alone can eat
11
+ // the whole diff budget). They keep only their stats header line.
12
+ const GENERATED_FILE_PATTERNS = [
13
+ // Dependency lockfiles (yarn.lock, Cargo.lock, etc. end in .lock).
14
+ /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|bun\.lockb|go\.sum)$/,
15
+ /\.lock$/,
16
+ // Build artifacts and vendored dependencies.
17
+ /(^|\/)(dist|build|out|node_modules|vendor|coverage|__snapshots__|\.next|\.nuxt)\//,
18
+ // Minified bundles, sourcemaps, test snapshots and vector/base64 assets.
19
+ /\.min\.(js|css)$/,
20
+ /\.(map|snap|svg)$/,
21
+ ];
22
+ function isGeneratedPath(filePath) {
23
+ return GENERATED_FILE_PATTERNS.some((pattern) => pattern.test(filePath));
24
+ }
25
+ // Filenames that frequently hold credentials. Redacted so a committed .env or
26
+ // private key is never sent verbatim to the backend / model: keep the
27
+ // `### path (+a/-r)` header, drop the body. Mirrored byte-for-byte in the VS
28
+ // Code extension's src/util/optimize.ts to keep the dedup contract identical.
29
+ const SENSITIVE_FILE_PATTERNS = [
30
+ /^\.env($|\.)/i,
31
+ /(^|[._-])secret/i,
32
+ /(^|[._-])secrets/i,
33
+ /(^|[._-])credential/i,
34
+ /(^|[._-])password/i,
35
+ /serviceaccount/i,
36
+ /(^|[._-])firebase.*config/i,
37
+ /(^|[._-])gcp.*key/i,
38
+ /(^|[._-])aws.*credentials/i,
39
+ /\.pem$/i,
40
+ /\.key$/i,
41
+ /\.pfx$/i,
42
+ /\.p12$/i,
43
+ /^id_(rsa|ed25519|ecdsa|dsa)/i,
44
+ ];
45
+ function isSensitivePath(filePath) {
46
+ const slash = filePath.lastIndexOf("/");
47
+ const base = slash === -1 ? filePath : filePath.slice(slash + 1);
48
+ return SENSITIVE_FILE_PATTERNS.some((pattern) => pattern.test(base));
49
+ }
50
+ /** The regex pipeline shared by every git-mode chunk. */
51
+ function compressDiffBody(input) {
52
+ return (input
53
+ // 1. Git headers and metadata (including the new/deleted/renamed/binary
54
+ // file and mode-change lines, which used to leak into the context).
55
+ .replace(/^(diff --git|index|new file mode|deleted file mode|old mode|new mode|similarity index|dissimilarity index|rename from|rename to|copy from|copy to|Binary files|---|\+\+\+|@@).*$/gm, "")
56
+ // 2. JS/TS/Java/C line comments
57
+ .replace(/^[-+ ]?\s*\/\/.*$/gm, "")
58
+ // 3. JS/TS/Java/C block comments
59
+ .replace(/\/\*[\s\S]*?\*\//g, "")
60
+ // 4. Python/Ruby line comments
61
+ .replace(/^[-+ ]?\s*#.*$/gm, "")
62
+ // 5. Python docstrings (""" or ''')
63
+ .replace(/("""[\s\S]*?"""|'''[\s\S]*?''')/g, "")
64
+ // 6. Collapse spaces and tabs
65
+ .replace(/[ \t]+/g, " ")
66
+ // 7. Drop now-empty lines
67
+ .replace(/^\s*$/gm, "")
68
+ // 8. Collapse consecutive newlines
69
+ .replace(/\n{2,}/g, "\n")
70
+ .trim());
71
+ }
72
+ /**
73
+ * Extracts the file path from a chunk's `diff --git` header, handling git's
74
+ * QUOTED form for paths with spaces or non-ASCII characters
75
+ * (`diff --git "a/mi módulo.ts" "b/mi módulo.ts"`, octal-escaped under the
76
+ * default core.quotepath). Returns null if there's no recognizable header.
77
+ */
78
+ function parseFilePath(chunk) {
79
+ const line = chunk.match(/^diff --git .*$/m)?.[0];
80
+ if (!line)
81
+ return null;
82
+ const quoted = line.match(/ "b\/((?:[^"\\]|\\.)*)"\s*$/);
83
+ if (quoted)
84
+ return unquoteGitPath(quoted[1]);
85
+ const idx = line.lastIndexOf(" b/");
86
+ if (idx !== -1) {
87
+ const path = line.slice(idx + 3).trim();
88
+ if (path)
89
+ return path;
90
+ }
91
+ return null;
92
+ }
93
+ /** Reverses git's C-style quoting (octal \NNN bytes, \t \n \" \\). */
94
+ function unquoteGitPath(escaped) {
95
+ const bytes = [];
96
+ for (let i = 0; i < escaped.length; i++) {
97
+ if (escaped[i] !== "\\") {
98
+ bytes.push(escaped.charCodeAt(i));
99
+ continue;
100
+ }
101
+ const next = escaped[i + 1];
102
+ const octal = escaped.slice(i + 1, i + 4);
103
+ if (/^[0-7]{3}$/.test(octal)) {
104
+ bytes.push(parseInt(octal, 8));
105
+ i += 3;
106
+ }
107
+ else {
108
+ const map = {
109
+ t: 9,
110
+ n: 10,
111
+ r: 13,
112
+ '"': 34,
113
+ "\\": 92,
114
+ };
115
+ bytes.push(map[next] ?? next.charCodeAt(0));
116
+ i += 1;
117
+ }
118
+ }
119
+ try {
120
+ return new TextDecoder().decode(new Uint8Array(bytes));
121
+ }
122
+ catch {
123
+ return escaped;
124
+ }
125
+ }
126
+ function countLineChanges(chunk) {
127
+ let added = 0;
128
+ let removed = 0;
129
+ for (const line of chunk.split("\n")) {
130
+ if (line.startsWith("+") && !line.startsWith("+++"))
131
+ added += 1;
132
+ else if (line.startsWith("-") && !line.startsWith("---"))
133
+ removed += 1;
134
+ }
135
+ return { added, removed };
136
+ }
137
+ /**
138
+ * Truncates a per-file-formatted compressed diff at a FILE boundary (the last
139
+ * `### ` header at or before `limit`), never mid-header. A blind char slice
140
+ * would cut a trailing `### path (+a/-r)` header in half, so the server's
141
+ * extractTouchedFiles would miss those files and the atlas filter would drop
142
+ * their modules. Falls back to a plain slice if there's no boundary to cut at.
143
+ */
144
+ export function truncateAtFileBoundary(diff, limit) {
145
+ if (diff.length <= limit)
146
+ return diff;
147
+ const boundary = diff.lastIndexOf("\n### ", limit);
148
+ if (boundary > 0)
149
+ return diff.slice(0, boundary);
150
+ return diff.slice(0, limit);
151
+ }
152
+ export function optimizeTokensForAI(rawInput) {
153
+ if (!rawInput) {
154
+ return "";
155
+ }
156
+ // Input without git-diff structure (loose patches): flat pipeline.
157
+ if (!rawInput.includes("diff --git ")) {
158
+ return compressDiffBody(rawInput);
159
+ }
160
+ // One block per file with a `### path (+a/-r)` header. That format is a
161
+ // CONTRACT with analyze-diff, which extracts the touched files from those
162
+ // lines to filter the atlas context — changing it means changing the server.
163
+ const parts = [];
164
+ for (const chunk of rawInput.split(/^(?=diff --git )/m)) {
165
+ const filePath = parseFilePath(chunk);
166
+ if (!filePath) {
167
+ const body = compressDiffBody(chunk);
168
+ if (body)
169
+ parts.push(body);
170
+ continue;
171
+ }
172
+ const { added, removed } = countLineChanges(chunk);
173
+ const stats = `(+${added}/-${removed})`;
174
+ if (isSensitivePath(filePath)) {
175
+ parts.push(`### ${filePath} ${stats} [omitido: archivo sensible]`);
176
+ continue;
177
+ }
178
+ if (isGeneratedPath(filePath)) {
179
+ parts.push(`### ${filePath} ${stats} [generado, omitido]`);
180
+ continue;
181
+ }
182
+ let body = compressDiffBody(chunk);
183
+ if (body.length > PER_FILE_BUDGET_CHARS) {
184
+ const cut = body.lastIndexOf("\n", PER_FILE_BUDGET_CHARS);
185
+ body =
186
+ body.slice(0, cut > 0 ? cut : PER_FILE_BUDGET_CHARS) +
187
+ "\n[resto del archivo truncado]";
188
+ }
189
+ parts.push(body ? `### ${filePath} ${stats}\n${body}` : `### ${filePath} ${stats}`);
190
+ }
191
+ return parts.join("\n").trim();
192
+ }
193
+ //# sourceMappingURL=optimize.js.map