@gaberrb/polypus 0.4.9 → 0.4.11

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 CHANGED
@@ -142,6 +142,8 @@ var en = {
142
142
  "run.reprompt": "\u21BB no tool call \u2014 reinforcing instructions (attempt {attempt})",
143
143
  "run.autocorrect": "\u21BB tool failed \u2014 auto-correcting with extra context",
144
144
  "run.cancelled": "\u25A0 cancelled",
145
+ "compaction.done": "context compacted: ~{before} \u2192 ~{after} tokens",
146
+ "tools.customLoaded": "loaded custom tool(s): {names}",
145
147
  "run.jsonNeedsTask": "--json requires a task argument (headless mode has no interactive REPL).",
146
148
  "review.approveAll": "approve all",
147
149
  "review.reject": "reject",
@@ -401,6 +403,8 @@ var ptBR = {
401
403
  "run.reprompt": "\u21BB nenhuma chamada de tool \u2014 refor\xE7ando instru\xE7\xF5es (tentativa {attempt})",
402
404
  "run.autocorrect": "\u21BB tool falhou \u2014 autocorrigindo com contexto extra",
403
405
  "run.cancelled": "\u25A0 cancelado",
406
+ "compaction.done": "contexto compactado: ~{before} \u2192 ~{after} tokens",
407
+ "tools.customLoaded": "tool(s) customizada(s) carregada(s): {names}",
404
408
  "run.jsonNeedsTask": "--json exige um argumento de tarefa (o modo headless n\xE3o tem REPL interativo).",
405
409
  "review.approveAll": "aprovar tudo",
406
410
  "review.reject": "rejeitar",
@@ -2092,6 +2096,116 @@ async function loadProjectInstructions(workspace) {
2092
2096
  return void 0;
2093
2097
  }
2094
2098
 
2099
+ // src/core/agent/compaction.ts
2100
+ function estimateTokens(messages) {
2101
+ let chars = 0;
2102
+ for (const m of messages) chars += m.content.length;
2103
+ return Math.ceil(chars / 4);
2104
+ }
2105
+ var RECENT_KEEP = 8;
2106
+ var MIN_TO_COMPACT = 4;
2107
+ var MAX_SUMMARY_INPUT = 4e4;
2108
+ function findSafeCut(messages, desiredKeep = RECENT_KEEP) {
2109
+ let cut = Math.max(1, messages.length - desiredKeep);
2110
+ while (cut < messages.length && (messages[cut].role === "tool" || messages[cut - 1]?.role === "assistant" && (messages[cut - 1].toolCalls?.length ?? 0) > 0)) {
2111
+ cut++;
2112
+ }
2113
+ return cut;
2114
+ }
2115
+ function serialize(messages) {
2116
+ const text2 = messages.map((m) => {
2117
+ const tools = m.toolCalls?.length ? ` [called: ${m.toolCalls.map((c) => c.name).join(", ")}]` : "";
2118
+ return `${m.role}${tools}: ${m.content}`;
2119
+ }).join("\n\n");
2120
+ return text2.length > MAX_SUMMARY_INPUT ? text2.slice(-MAX_SUMMARY_INPUT) : text2;
2121
+ }
2122
+ async function compactHistory(messages, agent, signal) {
2123
+ if (messages.length === 0) return messages;
2124
+ const system = messages[0].role === "system" ? messages[0] : void 0;
2125
+ const startIdx = system ? 1 : 0;
2126
+ const cut = findSafeCut(messages);
2127
+ if (cut >= messages.length) return messages;
2128
+ const middle = messages.slice(startIdx, cut);
2129
+ if (middle.length < MIN_TO_COMPACT) return messages;
2130
+ const tail = messages.slice(cut);
2131
+ const summary = await agent.provider.chat({
2132
+ messages: [
2133
+ {
2134
+ role: "system",
2135
+ content: "You compress a coding agent's conversation so it can continue with less context. Summarize the messages below into a concise but information-dense brief that preserves: the original task and goal, key decisions, files created/edited and why, important command/test outputs, and any remaining TODOs or open problems. Use terse bullet points. Do not invent details."
2136
+ },
2137
+ { role: "user", content: serialize(middle) }
2138
+ ],
2139
+ signal
2140
+ });
2141
+ const summaryMessage = {
2142
+ role: "user",
2143
+ content: `[Summary of earlier conversation, compacted to save context]
2144
+ ${summary.content.trim()}`
2145
+ };
2146
+ return system ? [system, summaryMessage, ...tail] : [summaryMessage, ...tail];
2147
+ }
2148
+
2149
+ // src/core/agent/hooks.ts
2150
+ import { exec as exec2 } from "child_process";
2151
+ import { readFile as readFile8 } from "fs/promises";
2152
+ import { join as join4 } from "path";
2153
+ import { promisify as promisify2 } from "util";
2154
+ import { z as z8 } from "zod";
2155
+ var execAsync2 = promisify2(exec2);
2156
+ var HOOK_TIMEOUT = 12e4;
2157
+ var HooksSchema = z8.object({
2158
+ /** Shell command run after a successful write_file. `{path}` is substituted. */
2159
+ afterWrite: z8.string().optional(),
2160
+ /** Shell command run after a successful edit_file. `{path}` is substituted. */
2161
+ afterEdit: z8.string().optional(),
2162
+ /** Shell command run after any successful mutating tool. `{tool}`/`{path}` substituted. */
2163
+ afterTool: z8.string().optional(),
2164
+ /** Block run_command when the command contains any of these substrings. */
2165
+ beforeCommand: z8.object({ deny: z8.array(z8.string()).default([]) }).optional()
2166
+ });
2167
+ async function loadHooks(workspace) {
2168
+ try {
2169
+ const raw = await readFile8(join4(workspace, ".poly", "hooks.json"), "utf8");
2170
+ const parsed = HooksSchema.safeParse(JSON.parse(raw));
2171
+ return parsed.success ? parsed.data : void 0;
2172
+ } catch {
2173
+ return void 0;
2174
+ }
2175
+ }
2176
+ function screenCommandHook(hooks, command) {
2177
+ const deny = hooks?.beforeCommand?.deny ?? [];
2178
+ for (const needle of deny) {
2179
+ if (needle && command.includes(needle)) {
2180
+ return { blocked: true, reason: `matches deny rule "${needle}"` };
2181
+ }
2182
+ }
2183
+ return { blocked: false };
2184
+ }
2185
+ function substitute(template, call) {
2186
+ const path = typeof call.arguments.path === "string" ? call.arguments.path : "";
2187
+ return template.replace(/\{path\}/g, path).replace(/\{tool\}/g, call.name);
2188
+ }
2189
+ async function runAfterHook(hooks, call, workspace) {
2190
+ if (!hooks) return void 0;
2191
+ const commands = [];
2192
+ if (call.name === "write_file" && hooks.afterWrite) commands.push({ label: "afterWrite", cmd: hooks.afterWrite });
2193
+ if (call.name === "edit_file" && hooks.afterEdit) commands.push({ label: "afterEdit", cmd: hooks.afterEdit });
2194
+ if (hooks.afterTool) commands.push({ label: "afterTool", cmd: hooks.afterTool });
2195
+ if (commands.length === 0) return void 0;
2196
+ const notes = [];
2197
+ for (const { label, cmd } of commands) {
2198
+ const resolved = substitute(cmd, call);
2199
+ try {
2200
+ await execAsync2(resolved, { cwd: workspace, timeout: HOOK_TIMEOUT, windowsHide: true });
2201
+ notes.push(`\u21AA hook ${label} ok`);
2202
+ } catch (err) {
2203
+ notes.push(`\u21AA hook ${label} failed: ${err.message.split("\n")[0]}`);
2204
+ }
2205
+ }
2206
+ return notes.join("\n");
2207
+ }
2208
+
2095
2209
  // src/core/agent/loop.ts
2096
2210
  function looksLikeStall(text2) {
2097
2211
  const lc = text2.toLowerCase();
@@ -2132,7 +2246,13 @@ async function runAgent(opts) {
2132
2246
  const { agent, permissions, events } = opts;
2133
2247
  const maxSteps = opts.maxSteps ?? 30;
2134
2248
  const maxReprompts = opts.maxReprompts ?? 3;
2135
- const driver = makeDriver(agent.toolMode, toolSpecs());
2249
+ const extra = opts.extraTools ?? [];
2250
+ const extraByName = new Map(extra.map((tl) => [tl.spec.name, tl]));
2251
+ const baseSpecs = toolSpecs();
2252
+ const finishSpec = baseSpecs[baseSpecs.length - 1];
2253
+ const allSpecs = [...baseSpecs.slice(0, -1), ...extra.map((tl) => tl.spec), finishSpec];
2254
+ const resolveTool = (name) => extraByName.get(name) ?? getTool(name);
2255
+ const driver = makeDriver(agent.toolMode, allSpecs);
2136
2256
  const ctx = { workspace: opts.workspace, permissions };
2137
2257
  const seeding = !(opts.history && opts.history.length > 0);
2138
2258
  const promptContext = seeding && opts.promptContext.projectInstructions === void 0 ? { ...opts.promptContext, projectInstructions: await loadProjectInstructions(opts.workspace) } : opts.promptContext;
@@ -2146,9 +2266,22 @@ async function runAgent(opts) {
2146
2266
  const maxToolRetries = opts.maxToolRetries ?? 3;
2147
2267
  const autoCorrect = opts.autoCorrect ?? true;
2148
2268
  const usage2 = { promptTokens: 0, completionTokens: 0 };
2269
+ const compactThreshold = opts.compactThresholdTokens ?? 0;
2270
+ let lastPromptTokens = 0;
2149
2271
  for (let step = 1; step <= maxSteps; step++) {
2150
2272
  if (opts.signal?.aborted) return { finished: false, reason: "cancelled", steps: step - 1, messages, usage: usage2 };
2151
2273
  events?.onStep?.(step);
2274
+ if (compactThreshold > 0) {
2275
+ const current = lastPromptTokens || estimateTokens(messages);
2276
+ if (current >= compactThreshold) {
2277
+ const compacted = await compactHistory(messages, agent, opts.signal);
2278
+ if (compacted.length < messages.length) {
2279
+ messages.splice(0, messages.length, ...compacted);
2280
+ lastPromptTokens = estimateTokens(messages);
2281
+ events?.onCompaction?.(current, lastPromptTokens);
2282
+ }
2283
+ }
2284
+ }
2152
2285
  let response;
2153
2286
  try {
2154
2287
  response = await agent.provider.chat({
@@ -2163,6 +2296,7 @@ async function runAgent(opts) {
2163
2296
  }
2164
2297
  usage2.promptTokens += response.usage?.promptTokens ?? 0;
2165
2298
  usage2.completionTokens += response.usage?.completionTokens ?? 0;
2299
+ lastPromptTokens = response.usage?.promptTokens ?? estimateTokens(messages);
2166
2300
  events?.onUsage?.(usage2);
2167
2301
  const { toolCalls, text: text2 } = driver.parse(response);
2168
2302
  messages.push(driver.assistantMessage(response, toolCalls));
@@ -2199,8 +2333,21 @@ async function runAgent(opts) {
2199
2333
  const summary = String(call.arguments.summary ?? "").trim();
2200
2334
  return { finished: true, reason: "finished", summary, steps: step, messages, usage: usage2 };
2201
2335
  }
2202
- const tool = getTool(call.name);
2203
- const result = tool ? await tool.run(call.arguments, ctx) : { ok: false, output: `Unknown tool "${call.name}". Available: ${toolSpecs().map((t2) => t2.name).join(", ")}` };
2336
+ const tool = resolveTool(call.name);
2337
+ const hookScreen = call.name === "run_command" ? screenCommandHook(opts.hooks, String(call.arguments.command ?? "")) : { blocked: false };
2338
+ let result;
2339
+ if (hookScreen.blocked) {
2340
+ result = { ok: false, output: `Command blocked by hook: ${hookScreen.reason}` };
2341
+ } else if (tool) {
2342
+ result = await tool.run(call.arguments, ctx);
2343
+ if (result.ok) {
2344
+ const note2 = await runAfterHook(opts.hooks, call, opts.workspace);
2345
+ if (note2) result = { ...result, output: `${result.output}
2346
+ ${note2}` };
2347
+ }
2348
+ } else {
2349
+ result = { ok: false, output: `Unknown tool "${call.name}". Available: ${allSpecs.map((t2) => t2.name).join(", ")}` };
2350
+ }
2204
2351
  events?.onToolResult?.(call, result);
2205
2352
  const sig = `${call.name}:${JSON.stringify(call.arguments)}`;
2206
2353
  let resultText = result.output;
@@ -2244,7 +2391,7 @@ ${guidance}`;
2244
2391
  }
2245
2392
 
2246
2393
  // src/core/context/mentions.ts
2247
- import { readdir as readdir4, readFile as readFile8, stat as stat2 } from "fs/promises";
2394
+ import { readdir as readdir4, readFile as readFile9, stat as stat2 } from "fs/promises";
2248
2395
  import { resolve as resolve9 } from "path";
2249
2396
  var MAX_FILE_CHARS = 1e4;
2250
2397
  var MENTION_RE = /(?:^|\s)@([\w./-]+)/g;
@@ -2271,7 +2418,7 @@ ${t("mentions.notFound", { path: token })}`);
2271
2418
  ${listing || "(empty)"}`);
2272
2419
  injected.push(decision.rel);
2273
2420
  } else {
2274
- const raw = await readFile8(abs, "utf8");
2421
+ const raw = await readFile9(abs, "utf8");
2275
2422
  const content = raw.length > MAX_FILE_CHARS ? raw.slice(0, MAX_FILE_CHARS) + "\n\u2026[truncated]" : raw;
2276
2423
  blocks.push(`## @${decision.rel}
2277
2424
  \`\`\`
@@ -2294,16 +2441,16 @@ ${blocks.join("\n\n")}`;
2294
2441
  }
2295
2442
 
2296
2443
  // src/core/agent/verify.ts
2297
- import { exec as exec2 } from "child_process";
2298
- import { readFile as readFile9 } from "fs/promises";
2444
+ import { exec as exec3 } from "child_process";
2445
+ import { readFile as readFile10 } from "fs/promises";
2299
2446
  import { resolve as resolve10 } from "path";
2300
- import { promisify as promisify2 } from "util";
2301
- var execAsync2 = promisify2(exec2);
2447
+ import { promisify as promisify3 } from "util";
2448
+ var execAsync3 = promisify3(exec3);
2302
2449
  var MAX_OUTPUT3 = 8e3;
2303
2450
  var CHECK_SCRIPTS = ["typecheck", "build", "test"];
2304
2451
  async function detectChecks(workspace) {
2305
2452
  try {
2306
- const raw = await readFile9(resolve10(workspace, "package.json"), "utf8");
2453
+ const raw = await readFile10(resolve10(workspace, "package.json"), "utf8");
2307
2454
  const scripts = JSON.parse(raw).scripts ?? {};
2308
2455
  return CHECK_SCRIPTS.filter((s) => typeof scripts[s] === "string").map((s) => `npm run ${s}`);
2309
2456
  } catch {
@@ -2314,7 +2461,7 @@ async function runChecks(workspace, commands) {
2314
2461
  const results = [];
2315
2462
  for (const command of commands) {
2316
2463
  try {
2317
- const { stdout: stdout2, stderr } = await execAsync2(command, {
2464
+ const { stdout: stdout2, stderr } = await execAsync3(command, {
2318
2465
  cwd: workspace,
2319
2466
  timeout: 3e5,
2320
2467
  maxBuffer: 10 * 1024 * 1024,
@@ -2346,8 +2493,8 @@ function clamp2(s) {
2346
2493
  }
2347
2494
 
2348
2495
  // src/core/agent/usage.ts
2349
- import { appendFile, mkdir as mkdir3, readFile as readFile10 } from "fs/promises";
2350
- import { join as join4 } from "path";
2496
+ import { appendFile, mkdir as mkdir3, readFile as readFile11 } from "fs/promises";
2497
+ import { join as join5 } from "path";
2351
2498
 
2352
2499
  // src/core/providers/openrouter.ts
2353
2500
  var MODELS_URL = "https://openrouter.ai/api/v1/models";
@@ -2447,7 +2594,7 @@ function fmtUsd(n) {
2447
2594
  return `US$${n.toFixed(2)}`;
2448
2595
  }
2449
2596
  function usagePath() {
2450
- return join4(configDir(), "usage.jsonl");
2597
+ return join5(configDir(), "usage.jsonl");
2451
2598
  }
2452
2599
  async function recordUsage(entry) {
2453
2600
  try {
@@ -2459,7 +2606,7 @@ async function recordUsage(entry) {
2459
2606
  async function aggregateUsage() {
2460
2607
  let text2 = "";
2461
2608
  try {
2462
- text2 = await readFile10(usagePath(), "utf8");
2609
+ text2 = await readFile11(usagePath(), "utf8");
2463
2610
  } catch {
2464
2611
  return { days: [], total: emptyBucket("total") };
2465
2612
  }
@@ -2493,10 +2640,10 @@ function accumulate(bucket, e) {
2493
2640
  }
2494
2641
 
2495
2642
  // src/core/agent/session-store.ts
2496
- import { mkdir as mkdir4, readFile as readFile11, readdir as readdir5, writeFile as writeFile4 } from "fs/promises";
2497
- import { join as join5 } from "path";
2643
+ import { mkdir as mkdir4, readFile as readFile12, readdir as readdir5, writeFile as writeFile4 } from "fs/promises";
2644
+ import { join as join6 } from "path";
2498
2645
  function sessionsDir() {
2499
- return join5(configDir(), "sessions");
2646
+ return join6(configDir(), "sessions");
2500
2647
  }
2501
2648
  function newSessionId() {
2502
2649
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
@@ -2504,7 +2651,7 @@ function newSessionId() {
2504
2651
  return `${stamp}-${rand}`;
2505
2652
  }
2506
2653
  function sessionPath(id) {
2507
- return join5(sessionsDir(), `${id}.json`);
2654
+ return join6(sessionsDir(), `${id}.json`);
2508
2655
  }
2509
2656
  async function saveSession(record) {
2510
2657
  await mkdir4(sessionsDir(), { recursive: true });
@@ -2516,7 +2663,7 @@ async function saveSession(record) {
2516
2663
  }
2517
2664
  async function loadSession(id) {
2518
2665
  try {
2519
- return JSON.parse(await readFile11(sessionPath(id), "utf8"));
2666
+ return JSON.parse(await readFile12(sessionPath(id), "utf8"));
2520
2667
  } catch {
2521
2668
  return void 0;
2522
2669
  }
@@ -2531,7 +2678,7 @@ async function listSessions() {
2531
2678
  const summaries = [];
2532
2679
  for (const f of files) {
2533
2680
  try {
2534
- const r = JSON.parse(await readFile11(join5(sessionsDir(), f), "utf8"));
2681
+ const r = JSON.parse(await readFile12(join6(sessionsDir(), f), "utf8"));
2535
2682
  summaries.push({
2536
2683
  id: r.id,
2537
2684
  updatedAt: r.updatedAt,
@@ -2555,6 +2702,83 @@ function deriveTitle(messages) {
2555
2702
  return text2.length > 60 ? text2.slice(0, 60) + "\u2026" : text2 || "(untitled)";
2556
2703
  }
2557
2704
 
2705
+ // src/core/tools/custom.ts
2706
+ import { exec as exec4 } from "child_process";
2707
+ import { readFile as readFile13, readdir as readdir6 } from "fs/promises";
2708
+ import { join as join7 } from "path";
2709
+ import { promisify as promisify4 } from "util";
2710
+ import { z as z9 } from "zod";
2711
+ var execAsync4 = promisify4(exec4);
2712
+ var MAX_OUTPUT4 = 2e4;
2713
+ var CustomToolSchema = z9.object({
2714
+ name: z9.string().min(1).regex(/^[a-z][a-z0-9_]*$/i, "tool name must be alphanumeric/underscore"),
2715
+ description: z9.string().min(1),
2716
+ /** JSON-schema object for the tool parameters (advertised to the model). */
2717
+ parameters: z9.record(z9.unknown()).optional(),
2718
+ /** Shell command template; `{argName}` placeholders are filled from the call arguments. */
2719
+ command: z9.string().min(1)
2720
+ });
2721
+ function makeCommandTool(def) {
2722
+ return {
2723
+ mutating: true,
2724
+ spec: {
2725
+ name: def.name,
2726
+ description: def.description,
2727
+ parameters: def.parameters ?? { type: "object", properties: {} }
2728
+ },
2729
+ async run(rawArgs, ctx) {
2730
+ const command = fillTemplate(def.command, rawArgs);
2731
+ const decision = await ctx.permissions.authorizeCommand(command);
2732
+ if (!decision.allowed) return { ok: false, output: `Command denied: ${decision.reason}` };
2733
+ try {
2734
+ const { stdout: stdout2, stderr } = await execAsync4(command, {
2735
+ cwd: ctx.workspace,
2736
+ timeout: 12e4,
2737
+ maxBuffer: 10 * 1024 * 1024,
2738
+ windowsHide: true
2739
+ });
2740
+ return { ok: true, output: clamp3(`${stdout2}${stderr ? `
2741
+ [stderr]
2742
+ ${stderr}` : ""}`.trim() || "(no output)") };
2743
+ } catch (err) {
2744
+ const e = err;
2745
+ return {
2746
+ ok: false,
2747
+ output: clamp3(`Command failed (exit ${e.code ?? "?"}): ${e.message}
2748
+ ${e.stdout ?? ""}${e.stderr ?? ""}`)
2749
+ };
2750
+ }
2751
+ }
2752
+ };
2753
+ }
2754
+ async function loadCustomTools(workspace) {
2755
+ let files;
2756
+ try {
2757
+ files = (await readdir6(join7(workspace, ".poly", "tools"))).filter((f) => f.endsWith(".json"));
2758
+ } catch {
2759
+ return [];
2760
+ }
2761
+ const tools = [];
2762
+ for (const f of files) {
2763
+ try {
2764
+ const raw = await readFile13(join7(workspace, ".poly", "tools", f), "utf8");
2765
+ const parsed = CustomToolSchema.safeParse(JSON.parse(raw));
2766
+ if (parsed.success) tools.push(makeCommandTool(parsed.data));
2767
+ } catch {
2768
+ }
2769
+ }
2770
+ return tools;
2771
+ }
2772
+ function fillTemplate(template, args) {
2773
+ return template.replace(/\{(\w+)\}/g, (_, key) => {
2774
+ const v = args[key];
2775
+ return v === void 0 || v === null ? "" : String(v);
2776
+ });
2777
+ }
2778
+ function clamp3(s) {
2779
+ return s.length > MAX_OUTPUT4 ? s.slice(0, MAX_OUTPUT4) + "\n\u2026[truncated]" : s;
2780
+ }
2781
+
2558
2782
  // src/cli/commands/json-output.ts
2559
2783
  var OUTPUT_PREVIEW = 500;
2560
2784
  function createJsonCollector() {
@@ -2588,6 +2812,9 @@ function createJsonCollector() {
2588
2812
  onReprompt(attempt) {
2589
2813
  log.push({ type: "reprompt", attempt });
2590
2814
  },
2815
+ onCompaction(before, after) {
2816
+ log.push({ type: "compaction", before, after });
2817
+ },
2591
2818
  onUsage() {
2592
2819
  }
2593
2820
  };
@@ -3350,7 +3577,7 @@ import pc7 from "picocolors";
3350
3577
  // src/core/git/worktree.ts
3351
3578
  import { mkdtemp } from "fs/promises";
3352
3579
  import { tmpdir } from "os";
3353
- import { join as join6 } from "path";
3580
+ import { join as join8 } from "path";
3354
3581
  import { simpleGit } from "simple-git";
3355
3582
  async function ensureRepo(workspace) {
3356
3583
  const git = simpleGit(workspace);
@@ -3371,7 +3598,7 @@ async function identityArgs(git) {
3371
3598
  }
3372
3599
  async function createWorktree(git, label) {
3373
3600
  const branch = `polypus/${label}-${Date.now().toString(36)}`;
3374
- const path = await mkdtemp(join6(tmpdir(), "polypus-wt-"));
3601
+ const path = await mkdtemp(join8(tmpdir(), "polypus-wt-"));
3375
3602
  await git.raw(["worktree", "add", "-b", branch, path, "HEAD"]);
3376
3603
  return { path, branch };
3377
3604
  }
@@ -3800,6 +4027,11 @@ var Spinner = class {
3800
4027
 
3801
4028
  // src/cli/commands/run.ts
3802
4029
  var MAX_VERIFY_FIXES = 3;
4030
+ function compactionThreshold() {
4031
+ if (process.env.POLYPUS_NO_COMPACT) return 0;
4032
+ const v = Number(process.env.POLYPUS_COMPACT_THRESHOLD);
4033
+ return Number.isFinite(v) && v > 0 ? v : 12e4;
4034
+ }
3803
4035
  async function run(task, opts) {
3804
4036
  let config = await loadConfig();
3805
4037
  const workspace = process.cwd();
@@ -3917,6 +4149,10 @@ async function executeTask(task, resolved, workspace, session, json = false, ver
3917
4149
  return ok;
3918
4150
  }
3919
4151
  });
4152
+ const [extraTools, hooks] = await Promise.all([loadCustomTools(workspace), loadHooks(workspace)]);
4153
+ if (!json && extraTools.length > 0) {
4154
+ console.log(pc8.dim(t("tools.customLoaded", { names: extraTools.map((tl) => tl.spec.name).join(", ") })));
4155
+ }
3920
4156
  const runOnce = (taskText) => runAgent({
3921
4157
  task: taskText,
3922
4158
  workspace,
@@ -3925,6 +4161,9 @@ async function executeTask(task, resolved, workspace, session, json = false, ver
3925
4161
  promptContext: { workspace, mode: session.mode, allow: session.allow },
3926
4162
  history: session.history,
3927
4163
  maxSteps: session.maxSteps,
4164
+ compactThresholdTokens: compactionThreshold(),
4165
+ extraTools,
4166
+ hooks,
3928
4167
  signal: controller.signal,
3929
4168
  events
3930
4169
  });
@@ -4108,6 +4347,10 @@ function renderEvents(spinner3) {
4108
4347
  spinner3.stop();
4109
4348
  console.log(pc8.yellow(" " + t("run.reprompt", { attempt })));
4110
4349
  },
4350
+ onCompaction(before, after) {
4351
+ spinner3.stop();
4352
+ console.log(pc8.dim("\u21AF " + t("compaction.done", { before: fmtTokens(before), after: fmtTokens(after) })));
4353
+ },
4111
4354
  onCorrection() {
4112
4355
  spinner3.stop();
4113
4356
  console.log(pc8.yellow(" \u21BB " + t("run.autocorrect")));
@@ -4125,7 +4368,7 @@ import pc9 from "picocolors";
4125
4368
 
4126
4369
  // src/core/scaffold/init.ts
4127
4370
  import { mkdir as mkdir5, writeFile as writeFile5, access } from "fs/promises";
4128
- import { dirname as dirname3, join as join7 } from "path";
4371
+ import { dirname as dirname3, join as join9 } from "path";
4129
4372
 
4130
4373
  // src/core/scaffold/templates.ts
4131
4374
  function polyTemplates(locale) {
@@ -4368,7 +4611,7 @@ async function scaffoldPoly(workspace, opts) {
4368
4611
  const skipped = [];
4369
4612
  for (const [rel, content] of Object.entries(templates)) {
4370
4613
  const display = `.poly/${rel}`;
4371
- const abs = join7(workspace, ".poly", ...rel.split("/"));
4614
+ const abs = join9(workspace, ".poly", ...rel.split("/"));
4372
4615
  if (!opts.force && await exists(abs)) {
4373
4616
  skipped.push(display);
4374
4617
  continue;
@@ -4508,9 +4751,9 @@ async function sessions() {
4508
4751
  }
4509
4752
 
4510
4753
  // src/cli/commands/prd.ts
4511
- import { writeFile as writeFile6, readFile as readFile12 } from "fs/promises";
4754
+ import { writeFile as writeFile6, readFile as readFile14 } from "fs/promises";
4512
4755
  import { execFile } from "child_process";
4513
- import { promisify as promisify3 } from "util";
4756
+ import { promisify as promisify5 } from "util";
4514
4757
  import pc13 from "picocolors";
4515
4758
 
4516
4759
  // src/core/agent/prd.ts
@@ -4632,7 +4875,7 @@ function stripBom(s) {
4632
4875
  }
4633
4876
 
4634
4877
  // src/cli/commands/prd.ts
4635
- var exec3 = promisify3(execFile);
4878
+ var exec5 = promisify5(execFile);
4636
4879
  async function prd(issueRef, opts) {
4637
4880
  const issue = await loadIssue(issueRef, opts.input);
4638
4881
  const { provider } = resolveFreeProvider(opts.model ?? DEFAULT_PRD_MODEL);
@@ -4647,11 +4890,11 @@ async function prd(issueRef, opts) {
4647
4890
  }
4648
4891
  async function loadIssue(issueRef, input) {
4649
4892
  if (input) {
4650
- const raw = input === "-" ? await readStdin() : await readFile12(input, "utf8");
4893
+ const raw = input === "-" ? await readStdin() : await readFile14(input, "utf8");
4651
4894
  return normalize2(JSON.parse(stripBom(raw)));
4652
4895
  }
4653
4896
  const num = numericRef(issueRef);
4654
- const { stdout: stdout2 } = await exec3("gh", ["issue", "view", num, "--json", "number,title,body,comments"]);
4897
+ const { stdout: stdout2 } = await exec5("gh", ["issue", "view", num, "--json", "number,title,body,comments"]);
4655
4898
  const data = normalize2(JSON.parse(stdout2));
4656
4899
  data.number ??= Number(num);
4657
4900
  return data;
@@ -4666,9 +4909,9 @@ function normalize2(raw) {
4666
4909
  }
4667
4910
 
4668
4911
  // src/cli/commands/review.ts
4669
- import { writeFile as writeFile7, readFile as readFile13 } from "fs/promises";
4912
+ import { writeFile as writeFile7, readFile as readFile15 } from "fs/promises";
4670
4913
  import { execFile as execFile2 } from "child_process";
4671
- import { promisify as promisify4 } from "util";
4914
+ import { promisify as promisify6 } from "util";
4672
4915
  import pc14 from "picocolors";
4673
4916
 
4674
4917
  // src/core/agent/review.ts
@@ -4726,7 +4969,7 @@ ${projectGuide}`
4726
4969
  }
4727
4970
 
4728
4971
  // src/cli/commands/review.ts
4729
- var exec4 = promisify4(execFile2);
4972
+ var exec6 = promisify6(execFile2);
4730
4973
  async function review(prRef, opts) {
4731
4974
  const num = opts.input ? prRef.replace(/^#/, "") : numericRef(prRef);
4732
4975
  const diff = await loadDiff(num, opts.input);
@@ -4742,19 +4985,19 @@ async function review(prRef, opts) {
4742
4985
  }
4743
4986
  }
4744
4987
  async function loadDiff(num, input) {
4745
- if (input) return input === "-" ? readStdin() : readFile13(input, "utf8");
4746
- const { stdout: stdout2 } = await exec4("gh", ["pr", "diff", num]);
4988
+ if (input) return input === "-" ? readStdin() : readFile15(input, "utf8");
4989
+ const { stdout: stdout2 } = await exec6("gh", ["pr", "diff", num]);
4747
4990
  return stdout2;
4748
4991
  }
4749
4992
  async function loadMeta(num, input) {
4750
4993
  if (input) return { number: Number(num) || void 0, title: `PR ${num}`, body: "" };
4751
- const { stdout: stdout2 } = await exec4("gh", ["pr", "view", num, "--json", "number,title,body"]);
4994
+ const { stdout: stdout2 } = await exec6("gh", ["pr", "view", num, "--json", "number,title,body"]);
4752
4995
  const raw = JSON.parse(stdout2);
4753
4996
  return { number: raw.number, title: raw.title ?? "", body: raw.body ?? "" };
4754
4997
  }
4755
4998
 
4756
4999
  // src/cli/index.ts
4757
- import { join as join8 } from "path";
5000
+ import { join as join10 } from "path";
4758
5001
 
4759
5002
  // src/core/config/dotenv.ts
4760
5003
  import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
@@ -4825,7 +5068,7 @@ function buildProgram() {
4825
5068
  }
4826
5069
  async function main() {
4827
5070
  try {
4828
- loadDotenv([join8(configDir(), ".env"), join8(process.cwd(), ".env")]);
5071
+ loadDotenv([join10(configDir(), ".env"), join10(process.cwd(), ".env")]);
4829
5072
  await resolveLocale();
4830
5073
  await buildProgram().parseAsync(process.argv);
4831
5074
  } catch (err) {