@gaberrb/polypus 0.1.0 โ†’ 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/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
@@ -121,6 +121,7 @@ var en = {
121
121
  "run.stopped": "\u26A0 Stopped after {steps} steps without a finish signal. You can continue with another instruction.",
122
122
  "run.confirm": "Allow {summary}?",
123
123
  "run.reprompt": "\u21BB no tool call \u2014 reinforcing instructions (attempt {attempt})",
124
+ "run.autocorrect": "\u21BB tool failed \u2014 auto-correcting with extra context",
124
125
  "run.cancelled": "\u25A0 cancelled",
125
126
  // repl
126
127
  "repl.welcome": "Polypus interactive session.",
@@ -308,6 +309,7 @@ var ptBR = {
308
309
  "run.stopped": "\u26A0 Parou ap\xF3s {steps} passos sem sinal de conclus\xE3o. Voc\xEA pode continuar com outra instru\xE7\xE3o.",
309
310
  "run.confirm": "Permitir {summary}?",
310
311
  "run.reprompt": "\u21BB nenhuma chamada de tool \u2014 refor\xE7ando instru\xE7\xF5es (tentativa {attempt})",
312
+ "run.autocorrect": "\u21BB tool falhou \u2014 autocorrigindo com contexto extra",
311
313
  "run.cancelled": "\u25A0 cancelado",
312
314
  "repl.welcome": "Sess\xE3o interativa do Polypus.",
313
315
  "repl.welcomeHint": " Digite /help para comandos, /exit para sair.",
@@ -1442,6 +1444,182 @@ function getTool(name) {
1442
1444
  return TOOLS[name];
1443
1445
  }
1444
1446
 
1447
+ // src/core/agent/correction.ts
1448
+ import { readFile as readFile4, readdir as readdir2 } from "fs/promises";
1449
+ import { dirname as dirname2, resolve as resolve6 } from "path";
1450
+ async function buildCorrection(call, output, deps) {
1451
+ const deterministic = await deterministicCorrection(call, output, deps);
1452
+ if (deterministic) return deterministic;
1453
+ if (deps.escalate) return deps.escalate({ call, output, toolSpec: deps.toolSpec });
1454
+ return null;
1455
+ }
1456
+ async function deterministicCorrection(call, output, deps) {
1457
+ const path = typeof call.arguments.path === "string" ? call.arguments.path : void 0;
1458
+ if (call.name === "edit_file" && /was not found/i.test(output) && path) {
1459
+ const search = typeof call.arguments.search === "string" ? call.arguments.search : "";
1460
+ const snippet = await snippetNearSearch(deps.workspace, path, search);
1461
+ if (snippet) {
1462
+ return [
1463
+ "AUTO-CORRECTION \u2014 the 'search' text does not exist verbatim in the file.",
1464
+ "Here is the file's actual content (line-numbered). Copy the 'search' value EXACTLY",
1465
+ "from these lines (including indentation and whitespace), then resend edit_file:",
1466
+ "",
1467
+ snippet
1468
+ ].join("\n");
1469
+ }
1470
+ }
1471
+ if (call.name === "edit_file" && /matched \d+ times/i.test(output) && path) {
1472
+ const search = typeof call.arguments.search === "string" ? call.arguments.search : "";
1473
+ const lines = await occurrenceLines(deps.workspace, path, search);
1474
+ if (lines.length > 0) {
1475
+ return [
1476
+ `AUTO-CORRECTION \u2014 the 'search' text is not unique; it appears at lines ${lines.join(", ")}.`,
1477
+ "Add more surrounding lines to 'search' so it matches exactly one location, then resend."
1478
+ ].join("\n");
1479
+ }
1480
+ }
1481
+ if (/ENOENT|no such file/i.test(output) && path) {
1482
+ const listing = await listNearest(deps.workspace, path);
1483
+ if (listing) {
1484
+ return [
1485
+ "AUTO-CORRECTION \u2014 that path does not exist. Nearby existing entries:",
1486
+ "",
1487
+ listing,
1488
+ "",
1489
+ "Use list_dir to confirm the correct path, or create the parent directory first."
1490
+ ].join("\n");
1491
+ }
1492
+ }
1493
+ if (/denied|not allowed/i.test(output)) {
1494
+ return [
1495
+ "AUTO-CORRECTION \u2014 this action was blocked by the permission policy.",
1496
+ `Editable paths (glob allow-list): ${deps.allow.join(", ") || "(none)"}.`,
1497
+ "Target a path inside the allow-list, or stop and report that the change is out of scope."
1498
+ ].join("\n");
1499
+ }
1500
+ if (deps.toolSpec && /needs .* arguments|Invalid|required|Resend the tool call/i.test(output)) {
1501
+ return [
1502
+ "AUTO-CORRECTION \u2014 the call had missing or invalid arguments. Expected schema:",
1503
+ "",
1504
+ formatSchema(deps.toolSpec),
1505
+ "",
1506
+ "Resend the tool call with every required argument filled in."
1507
+ ].join("\n");
1508
+ }
1509
+ return null;
1510
+ }
1511
+ function makeLLMEscalator(provider) {
1512
+ return async ({ call, output, toolSpec }) => {
1513
+ const prompt = [
1514
+ "A tool call just failed. Diagnose WHY in one or two sentences, then give a concrete,",
1515
+ "actionable instruction for how to correct the call. Do not apologize and do not call any tool.",
1516
+ "",
1517
+ `Tool: ${call.name}`,
1518
+ `Arguments: ${JSON.stringify(call.arguments)}`,
1519
+ `Error: ${output}`,
1520
+ toolSpec ? `Schema: ${JSON.stringify(toolSpec.parameters)}` : ""
1521
+ ].join("\n");
1522
+ try {
1523
+ const res = await provider.chat({
1524
+ messages: [
1525
+ { role: "system", content: "You are a debugging assistant for an autonomous coding agent." },
1526
+ { role: "user", content: prompt }
1527
+ ],
1528
+ params: { maxTokens: 400 }
1529
+ });
1530
+ const text2 = res.content.trim();
1531
+ return text2 ? `AUTO-CORRECTION (diagnosis):
1532
+ ${text2}` : null;
1533
+ } catch {
1534
+ return null;
1535
+ }
1536
+ };
1537
+ }
1538
+ async function readWorkspaceFile(workspace, path) {
1539
+ try {
1540
+ return await readFile4(resolve6(workspace, path), "utf8");
1541
+ } catch {
1542
+ return null;
1543
+ }
1544
+ }
1545
+ function numberedWindow(content, center2, radius) {
1546
+ const lines = content.split("\n");
1547
+ const start = Math.max(0, center2 - radius);
1548
+ const end = Math.min(lines.length, center2 + radius + 1);
1549
+ const width = String(end).length;
1550
+ return lines.slice(start, end).map((line, i) => `${String(start + i + 1).padStart(width, " ")} | ${line}`).join("\n");
1551
+ }
1552
+ async function snippetNearSearch(workspace, path, search) {
1553
+ const content = await readWorkspaceFile(workspace, path);
1554
+ if (content === null) return null;
1555
+ const lines = content.split("\n");
1556
+ const anchor = bestAnchorLine(lines, search);
1557
+ return numberedWindow(content, anchor >= 0 ? anchor : 0, 6);
1558
+ }
1559
+ function bestAnchorLine(lines, search) {
1560
+ const target = search.split("\n").map((l) => l.trim()).find((l) => l.length > 0);
1561
+ if (!target) return -1;
1562
+ const targetWords = new Set(tokens(target));
1563
+ let best = -1;
1564
+ let bestScore = 0;
1565
+ lines.forEach((line, i) => {
1566
+ if (line.includes(target)) {
1567
+ if (bestScore < Number.MAX_SAFE_INTEGER) {
1568
+ best = i;
1569
+ bestScore = Number.MAX_SAFE_INTEGER;
1570
+ }
1571
+ return;
1572
+ }
1573
+ const score = tokens(line).filter((w) => targetWords.has(w)).length;
1574
+ if (score > bestScore) {
1575
+ best = i;
1576
+ bestScore = score;
1577
+ }
1578
+ });
1579
+ return best;
1580
+ }
1581
+ function tokens(s) {
1582
+ return s.split(/\W+/).filter((w) => w.length > 0);
1583
+ }
1584
+ async function occurrenceLines(workspace, path, search) {
1585
+ const content = await readWorkspaceFile(workspace, path);
1586
+ if (content === null || search.length === 0) return [];
1587
+ const out = [];
1588
+ let from = 0;
1589
+ for (; ; ) {
1590
+ const idx = content.indexOf(search, from);
1591
+ if (idx === -1) break;
1592
+ out.push(content.slice(0, idx).split("\n").length);
1593
+ from = idx + search.length;
1594
+ }
1595
+ return out;
1596
+ }
1597
+ async function listNearest(workspace, path) {
1598
+ let dir = dirname2(resolve6(workspace, path));
1599
+ for (let i = 0; i < 8; i++) {
1600
+ try {
1601
+ const entries = await readdir2(dir, { withFileTypes: true });
1602
+ const rel = dir === resolve6(workspace) ? "." : dir;
1603
+ const names = entries.slice(0, 40).map((e) => e.isDirectory() ? `${e.name}/` : e.name);
1604
+ return `${rel}:
1605
+ ${names.join(" ") || "(empty)"}`;
1606
+ } catch {
1607
+ const parent = dirname2(dir);
1608
+ if (parent === dir) return null;
1609
+ dir = parent;
1610
+ }
1611
+ }
1612
+ return null;
1613
+ }
1614
+ function formatSchema(spec) {
1615
+ const props = spec.parameters.properties ?? {};
1616
+ const required = new Set(spec.parameters.required ?? []);
1617
+ const lines = Object.entries(props).map(
1618
+ ([name, v]) => ` - ${name} (${v.type ?? "any"}, ${required.has(name) ? "required" : "optional"})` + (v.description ? `: ${v.description}` : "")
1619
+ );
1620
+ return lines.join("\n") || " (no parameters)";
1621
+ }
1622
+
1445
1623
  // src/core/agent/loop.ts
1446
1624
  function looksLikeStall(text2) {
1447
1625
  const lc = text2.toLowerCase();
@@ -1492,6 +1670,7 @@ async function runAgent(opts) {
1492
1670
  let lastFailSig = "";
1493
1671
  let failStreak = 0;
1494
1672
  const maxToolRetries = opts.maxToolRetries ?? 3;
1673
+ const autoCorrect = opts.autoCorrect ?? true;
1495
1674
  const usage = { promptTokens: 0, completionTokens: 0 };
1496
1675
  for (let step = 1; step <= maxSteps; step++) {
1497
1676
  if (opts.signal?.aborted) return { finished: false, reason: "cancelled", steps: step - 1, messages, usage };
@@ -1538,12 +1717,29 @@ async function runAgent(opts) {
1538
1717
  const tool = getTool(call.name);
1539
1718
  const result = tool ? await tool.run(call.arguments, ctx) : { ok: false, output: `Unknown tool "${call.name}". Available: ${toolSpecs().map((t2) => t2.name).join(", ")}` };
1540
1719
  events?.onToolResult?.(call, result);
1541
- messages.push(driver.toolResultMessage(call, result.output));
1720
+ const sig = `${call.name}:${JSON.stringify(call.arguments)}`;
1721
+ let resultText = result.output;
1722
+ if (!result.ok && autoCorrect) {
1723
+ const guidance = await buildCorrection(call, result.output, {
1724
+ workspace: opts.workspace,
1725
+ allow: opts.promptContext.allow,
1726
+ toolSpec: tool?.spec,
1727
+ // Only spend a fixer-LLM call once the model has already repeated a
1728
+ // failing call โ€” the first failure gets deterministic help for free.
1729
+ escalate: sig === lastFailSig ? makeLLMEscalator(agent.provider) : void 0
1730
+ });
1731
+ if (guidance) {
1732
+ resultText = `${result.output}
1733
+
1734
+ ${guidance}`;
1735
+ events?.onCorrection?.(call, guidance);
1736
+ }
1737
+ }
1738
+ messages.push(driver.toolResultMessage(call, resultText));
1542
1739
  if (result.ok) {
1543
1740
  failStreak = 0;
1544
1741
  lastFailSig = "";
1545
1742
  } else {
1546
- const sig = `${call.name}:${JSON.stringify(call.arguments)}`;
1547
1743
  failStreak = sig === lastFailSig ? failStreak + 1 : 1;
1548
1744
  lastFailSig = sig;
1549
1745
  if (failStreak >= maxToolRetries) {
@@ -2403,6 +2599,10 @@ function renderEvents(spinner3) {
2403
2599
  onReprompt(attempt) {
2404
2600
  spinner3.stop();
2405
2601
  console.log(pc7.yellow(" " + t("run.reprompt", { attempt })));
2602
+ },
2603
+ onCorrection() {
2604
+ spinner3.stop();
2605
+ console.log(pc7.yellow(" \u21BB " + t("run.autocorrect")));
2406
2606
  }
2407
2607
  };
2408
2608
  }