@gaberrb/polypus 0.1.0 โ†’ 0.2.1

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/README.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Polypus ๐Ÿ™
2
2
 
3
+ [![CI](https://github.com/GaberRB/polypus/actions/workflows/ci.yml/badge.svg)](https://github.com/GaberRB/polypus/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/@gaberrb/polypus?color=9266F5&logo=npm)](https://www.npmjs.com/package/@gaberrb/polypus)
5
+ [![license](https://img.shields.io/badge/license-MIT-AB8EFA)](LICENSE)
6
+
7
+ **๐Ÿ“– Site:** https://gaberrb.github.io/polypus/ ยท **๐Ÿ“ฆ npm:** [`@gaberrb/polypus`](https://www.npmjs.com/package/@gaberrb/polypus)
8
+
3
9
  An agentic coding harness that makes **any** AI API generate and apply code โ€”
4
10
  even models **without** native tool-calling. Bring your own keys: OpenRouter,
5
11
  Ollama, Anthropic, or any OpenAI-compatible endpoint. Run one agent, or split a
@@ -221,6 +227,20 @@ npm run typecheck
221
227
  npm test # vitest
222
228
  ```
223
229
 
230
+ ## Contributing
231
+
232
+ Contributions are welcome โ€” but **open an issue first**. Issues are triaged and labeled
233
+ `accepted` before any PR; PRs without a linked accepted issue don't pass CI. See
234
+ [CONTRIBUTING.md](CONTRIBUTING.md) for the full flow (issue โ†’ `accepted` โ†’ branch โ†’ PR โ†’ review).
235
+
236
+ ## Apoie o projeto
237
+
238
+ Se o Polypus te ajudou, vocรช pode apoiar o desenvolvimento via **PIX** ๐Ÿ’œ
239
+
240
+ > **Chave PIX (e-mail):** `gabrielriosbelmiro@gmail.com`
241
+
242
+ Qualquer valor รฉ muito bem-vindo e ajuda a manter o projeto vivo. Obrigado!
243
+
224
244
  ## Author
225
245
 
226
246
  **Gabriel Rios**
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  // src/cli/index.ts
4
4
  import { Command } from "commander";
5
+ import { createRequire } from "module";
5
6
  import pc10 from "picocolors";
6
7
 
7
8
  // src/cli/commands/add-agent.ts
@@ -121,6 +122,7 @@ var en = {
121
122
  "run.stopped": "\u26A0 Stopped after {steps} steps without a finish signal. You can continue with another instruction.",
122
123
  "run.confirm": "Allow {summary}?",
123
124
  "run.reprompt": "\u21BB no tool call \u2014 reinforcing instructions (attempt {attempt})",
125
+ "run.autocorrect": "\u21BB tool failed \u2014 auto-correcting with extra context",
124
126
  "run.cancelled": "\u25A0 cancelled",
125
127
  // repl
126
128
  "repl.welcome": "Polypus interactive session.",
@@ -308,6 +310,7 @@ var ptBR = {
308
310
  "run.stopped": "\u26A0 Parou ap\xF3s {steps} passos sem sinal de conclus\xE3o. Voc\xEA pode continuar com outra instru\xE7\xE3o.",
309
311
  "run.confirm": "Permitir {summary}?",
310
312
  "run.reprompt": "\u21BB nenhuma chamada de tool \u2014 refor\xE7ando instru\xE7\xF5es (tentativa {attempt})",
313
+ "run.autocorrect": "\u21BB tool falhou \u2014 autocorrigindo com contexto extra",
311
314
  "run.cancelled": "\u25A0 cancelado",
312
315
  "repl.welcome": "Sess\xE3o interativa do Polypus.",
313
316
  "repl.welcomeHint": " Digite /help para comandos, /exit para sair.",
@@ -1442,6 +1445,182 @@ function getTool(name) {
1442
1445
  return TOOLS[name];
1443
1446
  }
1444
1447
 
1448
+ // src/core/agent/correction.ts
1449
+ import { readFile as readFile4, readdir as readdir2 } from "fs/promises";
1450
+ import { dirname as dirname2, resolve as resolve6 } from "path";
1451
+ async function buildCorrection(call, output, deps) {
1452
+ const deterministic = await deterministicCorrection(call, output, deps);
1453
+ if (deterministic) return deterministic;
1454
+ if (deps.escalate) return deps.escalate({ call, output, toolSpec: deps.toolSpec });
1455
+ return null;
1456
+ }
1457
+ async function deterministicCorrection(call, output, deps) {
1458
+ const path = typeof call.arguments.path === "string" ? call.arguments.path : void 0;
1459
+ if (call.name === "edit_file" && /was not found/i.test(output) && path) {
1460
+ const search = typeof call.arguments.search === "string" ? call.arguments.search : "";
1461
+ const snippet = await snippetNearSearch(deps.workspace, path, search);
1462
+ if (snippet) {
1463
+ return [
1464
+ "AUTO-CORRECTION \u2014 the 'search' text does not exist verbatim in the file.",
1465
+ "Here is the file's actual content (line-numbered). Copy the 'search' value EXACTLY",
1466
+ "from these lines (including indentation and whitespace), then resend edit_file:",
1467
+ "",
1468
+ snippet
1469
+ ].join("\n");
1470
+ }
1471
+ }
1472
+ if (call.name === "edit_file" && /matched \d+ times/i.test(output) && path) {
1473
+ const search = typeof call.arguments.search === "string" ? call.arguments.search : "";
1474
+ const lines = await occurrenceLines(deps.workspace, path, search);
1475
+ if (lines.length > 0) {
1476
+ return [
1477
+ `AUTO-CORRECTION \u2014 the 'search' text is not unique; it appears at lines ${lines.join(", ")}.`,
1478
+ "Add more surrounding lines to 'search' so it matches exactly one location, then resend."
1479
+ ].join("\n");
1480
+ }
1481
+ }
1482
+ if (/ENOENT|no such file/i.test(output) && path) {
1483
+ const listing = await listNearest(deps.workspace, path);
1484
+ if (listing) {
1485
+ return [
1486
+ "AUTO-CORRECTION \u2014 that path does not exist. Nearby existing entries:",
1487
+ "",
1488
+ listing,
1489
+ "",
1490
+ "Use list_dir to confirm the correct path, or create the parent directory first."
1491
+ ].join("\n");
1492
+ }
1493
+ }
1494
+ if (/denied|not allowed/i.test(output)) {
1495
+ return [
1496
+ "AUTO-CORRECTION \u2014 this action was blocked by the permission policy.",
1497
+ `Editable paths (glob allow-list): ${deps.allow.join(", ") || "(none)"}.`,
1498
+ "Target a path inside the allow-list, or stop and report that the change is out of scope."
1499
+ ].join("\n");
1500
+ }
1501
+ if (deps.toolSpec && /needs .* arguments|Invalid|required|Resend the tool call/i.test(output)) {
1502
+ return [
1503
+ "AUTO-CORRECTION \u2014 the call had missing or invalid arguments. Expected schema:",
1504
+ "",
1505
+ formatSchema(deps.toolSpec),
1506
+ "",
1507
+ "Resend the tool call with every required argument filled in."
1508
+ ].join("\n");
1509
+ }
1510
+ return null;
1511
+ }
1512
+ function makeLLMEscalator(provider) {
1513
+ return async ({ call, output, toolSpec }) => {
1514
+ const prompt = [
1515
+ "A tool call just failed. Diagnose WHY in one or two sentences, then give a concrete,",
1516
+ "actionable instruction for how to correct the call. Do not apologize and do not call any tool.",
1517
+ "",
1518
+ `Tool: ${call.name}`,
1519
+ `Arguments: ${JSON.stringify(call.arguments)}`,
1520
+ `Error: ${output}`,
1521
+ toolSpec ? `Schema: ${JSON.stringify(toolSpec.parameters)}` : ""
1522
+ ].join("\n");
1523
+ try {
1524
+ const res = await provider.chat({
1525
+ messages: [
1526
+ { role: "system", content: "You are a debugging assistant for an autonomous coding agent." },
1527
+ { role: "user", content: prompt }
1528
+ ],
1529
+ params: { maxTokens: 400 }
1530
+ });
1531
+ const text2 = res.content.trim();
1532
+ return text2 ? `AUTO-CORRECTION (diagnosis):
1533
+ ${text2}` : null;
1534
+ } catch {
1535
+ return null;
1536
+ }
1537
+ };
1538
+ }
1539
+ async function readWorkspaceFile(workspace, path) {
1540
+ try {
1541
+ return await readFile4(resolve6(workspace, path), "utf8");
1542
+ } catch {
1543
+ return null;
1544
+ }
1545
+ }
1546
+ function numberedWindow(content, center2, radius) {
1547
+ const lines = content.split("\n");
1548
+ const start = Math.max(0, center2 - radius);
1549
+ const end = Math.min(lines.length, center2 + radius + 1);
1550
+ const width = String(end).length;
1551
+ return lines.slice(start, end).map((line, i) => `${String(start + i + 1).padStart(width, " ")} | ${line}`).join("\n");
1552
+ }
1553
+ async function snippetNearSearch(workspace, path, search) {
1554
+ const content = await readWorkspaceFile(workspace, path);
1555
+ if (content === null) return null;
1556
+ const lines = content.split("\n");
1557
+ const anchor = bestAnchorLine(lines, search);
1558
+ return numberedWindow(content, anchor >= 0 ? anchor : 0, 6);
1559
+ }
1560
+ function bestAnchorLine(lines, search) {
1561
+ const target = search.split("\n").map((l) => l.trim()).find((l) => l.length > 0);
1562
+ if (!target) return -1;
1563
+ const targetWords = new Set(tokens(target));
1564
+ let best = -1;
1565
+ let bestScore = 0;
1566
+ lines.forEach((line, i) => {
1567
+ if (line.includes(target)) {
1568
+ if (bestScore < Number.MAX_SAFE_INTEGER) {
1569
+ best = i;
1570
+ bestScore = Number.MAX_SAFE_INTEGER;
1571
+ }
1572
+ return;
1573
+ }
1574
+ const score = tokens(line).filter((w) => targetWords.has(w)).length;
1575
+ if (score > bestScore) {
1576
+ best = i;
1577
+ bestScore = score;
1578
+ }
1579
+ });
1580
+ return best;
1581
+ }
1582
+ function tokens(s) {
1583
+ return s.split(/\W+/).filter((w) => w.length > 0);
1584
+ }
1585
+ async function occurrenceLines(workspace, path, search) {
1586
+ const content = await readWorkspaceFile(workspace, path);
1587
+ if (content === null || search.length === 0) return [];
1588
+ const out = [];
1589
+ let from = 0;
1590
+ for (; ; ) {
1591
+ const idx = content.indexOf(search, from);
1592
+ if (idx === -1) break;
1593
+ out.push(content.slice(0, idx).split("\n").length);
1594
+ from = idx + search.length;
1595
+ }
1596
+ return out;
1597
+ }
1598
+ async function listNearest(workspace, path) {
1599
+ let dir = dirname2(resolve6(workspace, path));
1600
+ for (let i = 0; i < 8; i++) {
1601
+ try {
1602
+ const entries = await readdir2(dir, { withFileTypes: true });
1603
+ const rel = dir === resolve6(workspace) ? "." : dir;
1604
+ const names = entries.slice(0, 40).map((e) => e.isDirectory() ? `${e.name}/` : e.name);
1605
+ return `${rel}:
1606
+ ${names.join(" ") || "(empty)"}`;
1607
+ } catch {
1608
+ const parent = dirname2(dir);
1609
+ if (parent === dir) return null;
1610
+ dir = parent;
1611
+ }
1612
+ }
1613
+ return null;
1614
+ }
1615
+ function formatSchema(spec) {
1616
+ const props = spec.parameters.properties ?? {};
1617
+ const required = new Set(spec.parameters.required ?? []);
1618
+ const lines = Object.entries(props).map(
1619
+ ([name, v]) => ` - ${name} (${v.type ?? "any"}, ${required.has(name) ? "required" : "optional"})` + (v.description ? `: ${v.description}` : "")
1620
+ );
1621
+ return lines.join("\n") || " (no parameters)";
1622
+ }
1623
+
1445
1624
  // src/core/agent/loop.ts
1446
1625
  function looksLikeStall(text2) {
1447
1626
  const lc = text2.toLowerCase();
@@ -1492,6 +1671,7 @@ async function runAgent(opts) {
1492
1671
  let lastFailSig = "";
1493
1672
  let failStreak = 0;
1494
1673
  const maxToolRetries = opts.maxToolRetries ?? 3;
1674
+ const autoCorrect = opts.autoCorrect ?? true;
1495
1675
  const usage = { promptTokens: 0, completionTokens: 0 };
1496
1676
  for (let step = 1; step <= maxSteps; step++) {
1497
1677
  if (opts.signal?.aborted) return { finished: false, reason: "cancelled", steps: step - 1, messages, usage };
@@ -1538,12 +1718,29 @@ async function runAgent(opts) {
1538
1718
  const tool = getTool(call.name);
1539
1719
  const result = tool ? await tool.run(call.arguments, ctx) : { ok: false, output: `Unknown tool "${call.name}". Available: ${toolSpecs().map((t2) => t2.name).join(", ")}` };
1540
1720
  events?.onToolResult?.(call, result);
1541
- messages.push(driver.toolResultMessage(call, result.output));
1721
+ const sig = `${call.name}:${JSON.stringify(call.arguments)}`;
1722
+ let resultText = result.output;
1723
+ if (!result.ok && autoCorrect) {
1724
+ const guidance = await buildCorrection(call, result.output, {
1725
+ workspace: opts.workspace,
1726
+ allow: opts.promptContext.allow,
1727
+ toolSpec: tool?.spec,
1728
+ // Only spend a fixer-LLM call once the model has already repeated a
1729
+ // failing call โ€” the first failure gets deterministic help for free.
1730
+ escalate: sig === lastFailSig ? makeLLMEscalator(agent.provider) : void 0
1731
+ });
1732
+ if (guidance) {
1733
+ resultText = `${result.output}
1734
+
1735
+ ${guidance}`;
1736
+ events?.onCorrection?.(call, guidance);
1737
+ }
1738
+ }
1739
+ messages.push(driver.toolResultMessage(call, resultText));
1542
1740
  if (result.ok) {
1543
1741
  failStreak = 0;
1544
1742
  lastFailSig = "";
1545
1743
  } else {
1546
- const sig = `${call.name}:${JSON.stringify(call.arguments)}`;
1547
1744
  failStreak = sig === lastFailSig ? failStreak + 1 : 1;
1548
1745
  lastFailSig = sig;
1549
1746
  if (failStreak >= maxToolRetries) {
@@ -2403,6 +2600,10 @@ function renderEvents(spinner3) {
2403
2600
  onReprompt(attempt) {
2404
2601
  spinner3.stop();
2405
2602
  console.log(pc7.yellow(" " + t("run.reprompt", { attempt })));
2603
+ },
2604
+ onCorrection() {
2605
+ spinner3.stop();
2606
+ console.log(pc7.yellow(" \u21BB " + t("run.autocorrect")));
2406
2607
  }
2407
2608
  };
2408
2609
  }
@@ -2737,6 +2938,7 @@ function loadDotenv(paths) {
2737
2938
  }
2738
2939
 
2739
2940
  // src/cli/index.ts
2941
+ var { version: pkgVersion } = createRequire(import.meta.url)("../package.json");
2740
2942
  async function launchInteractive() {
2741
2943
  const config = await loadConfig();
2742
2944
  if (config.agents.length === 0) {
@@ -2762,7 +2964,7 @@ async function resolveLocale() {
2762
2964
  }
2763
2965
  function buildProgram() {
2764
2966
  const program = new Command();
2765
- program.name("polypus").description(t("cli.description")).version("0.1.0").option("--lang <locale>", t("cli.opt.lang")).action(() => launchInteractive());
2967
+ program.name("polypus").description(t("cli.description")).version(pkgVersion).option("--lang <locale>", t("cli.opt.lang")).action(() => launchInteractive());
2766
2968
  program.command("setup").description(t("cli.cmd.setup")).action(() => setup());
2767
2969
  program.command("add-agent").argument("<name>", t("cli.arg.addAgentName")).requiredOption("--provider <provider>", t("cli.opt.provider")).requiredOption("--model <model>", t("cli.opt.model")).option("--api-key <key>", t("cli.opt.apiKey")).option("--base-url <url>", t("cli.opt.baseUrl")).option("--tool-mode <mode>", t("cli.opt.toolMode"), "auto").option("--set-default", t("cli.opt.setDefault")).description(t("cli.cmd.addAgent")).action((name, opts) => addAgent(name, opts));
2768
2970
  program.command("remove-agent").argument("<name>", t("cli.arg.removeAgentName")).description(t("cli.cmd.removeAgent")).action((name) => removeAgent(name));