beecork 2.7.2 → 2.8.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/dist/index.js +209 -40
- package/package.json +4 -2
- package/skeleton/bridge.mjs +176 -0
- package/skills/browser-signals.md +61 -0
package/dist/index.js
CHANGED
|
@@ -1195,11 +1195,11 @@ function createMarkdownStream(write) {
|
|
|
1195
1195
|
}
|
|
1196
1196
|
|
|
1197
1197
|
// src/tools.ts
|
|
1198
|
-
import { readFile as readFile3, writeFile as writeFile2, appendFile, readdir as readdir2, mkdir as
|
|
1198
|
+
import { readFile as readFile3, writeFile as writeFile2, appendFile, readdir as readdir2, mkdir as mkdir3, stat, rename, chmod } from "node:fs/promises";
|
|
1199
1199
|
import { createReadStream } from "node:fs";
|
|
1200
1200
|
import { createInterface as createLineReader } from "node:readline";
|
|
1201
|
-
import { spawn as
|
|
1202
|
-
import { join as
|
|
1201
|
+
import { spawn as spawn4 } from "node:child_process";
|
|
1202
|
+
import { join as join4 } from "node:path";
|
|
1203
1203
|
import { lookup as dnsLookup } from "node:dns";
|
|
1204
1204
|
import { request as httpRequest } from "node:http";
|
|
1205
1205
|
import { request as httpsRequest } from "node:https";
|
|
@@ -1296,7 +1296,8 @@ function killAllTasks() {
|
|
|
1296
1296
|
|
|
1297
1297
|
// src/skills.ts
|
|
1298
1298
|
import { readdir, readFile as readFile2 } from "node:fs/promises";
|
|
1299
|
-
import { join as join2 } from "node:path";
|
|
1299
|
+
import { join as join2, dirname as dirname2 } from "node:path";
|
|
1300
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
1300
1301
|
import { homedir as homedir3 } from "node:os";
|
|
1301
1302
|
function parseSkill(raw) {
|
|
1302
1303
|
let body = raw;
|
|
@@ -1331,9 +1332,11 @@ function getSkill(name) {
|
|
|
1331
1332
|
}
|
|
1332
1333
|
async function loadSkills() {
|
|
1333
1334
|
registry.clear();
|
|
1335
|
+
const bundledDir = join2(dirname2(fileURLToPath2(import.meta.url)), "..", "skills");
|
|
1334
1336
|
const dirs = [
|
|
1335
1337
|
[join2(homedir3(), ".beecork", "skills"), "global"],
|
|
1336
|
-
[join2(process.cwd(), ".beecork", "skills"), "project"]
|
|
1338
|
+
[join2(process.cwd(), ".beecork", "skills"), "project"],
|
|
1339
|
+
[bundledDir, "bundled"]
|
|
1337
1340
|
];
|
|
1338
1341
|
for (const [dir, source] of dirs) {
|
|
1339
1342
|
let entries;
|
|
@@ -1346,13 +1349,13 @@ async function loadSkills() {
|
|
|
1346
1349
|
if (!e.isFile() || !e.name.endsWith(".md")) continue;
|
|
1347
1350
|
const name = e.name.slice(0, -3);
|
|
1348
1351
|
if (!/^[a-z0-9][a-z0-9_-]*$/i.test(name)) continue;
|
|
1352
|
+
if (registry.has(name)) {
|
|
1353
|
+
if (source === "project") console.error(color.yellow(`\u26A0 project skill /${name} ignored \u2014 a global skill of that name takes precedence`));
|
|
1354
|
+
continue;
|
|
1355
|
+
}
|
|
1349
1356
|
try {
|
|
1350
1357
|
const raw = (await readFile2(join2(dir, e.name), "utf8")).trim();
|
|
1351
1358
|
if (!raw) continue;
|
|
1352
|
-
if (source === "project" && registry.has(name)) {
|
|
1353
|
-
console.error(color.yellow(`\u26A0 project skill /${name} ignored \u2014 a global skill of that name takes precedence`));
|
|
1354
|
-
continue;
|
|
1355
|
-
}
|
|
1356
1359
|
const { description, modelInvocable, body } = parseSkill(raw);
|
|
1357
1360
|
registry.set(name, { name, content: body, description, modelInvocable, path: join2(dir, e.name), source });
|
|
1358
1361
|
} catch {
|
|
@@ -1675,11 +1678,80 @@ function decodeEntities(s) {
|
|
|
1675
1678
|
});
|
|
1676
1679
|
}
|
|
1677
1680
|
|
|
1681
|
+
// src/skeleton.ts
|
|
1682
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
1683
|
+
import { mkdir as mkdir2 } from "node:fs/promises";
|
|
1684
|
+
import { join as join3, dirname as dirname3 } from "node:path";
|
|
1685
|
+
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
1686
|
+
import { homedir as homedir5 } from "node:os";
|
|
1687
|
+
var port = () => Number(process.env.BEECORK_SKELETON_PORT) || 8317;
|
|
1688
|
+
var skeletonUrl = () => process.env.BEECORK_DEV_SIGNALS_URL || `http://localhost:${port()}`;
|
|
1689
|
+
var managedExternally = () => !!process.env.BEECORK_DEV_SIGNALS_URL;
|
|
1690
|
+
var skeletonHome = () => process.env.BEECORK_SKELETON_HOME || join3(homedir5(), ".beecork", "skeleton");
|
|
1691
|
+
var bridgeScript = () => join3(dirname3(fileURLToPath3(import.meta.url)), "..", "skeleton", "bridge.mjs");
|
|
1692
|
+
async function probe(url = skeletonUrl(), timeoutMs = 600) {
|
|
1693
|
+
try {
|
|
1694
|
+
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
1695
|
+
if (res.ok) {
|
|
1696
|
+
const j = await res.json();
|
|
1697
|
+
if (j && j.skeleton === true) return "up";
|
|
1698
|
+
}
|
|
1699
|
+
} catch {
|
|
1700
|
+
return "down";
|
|
1701
|
+
}
|
|
1702
|
+
try {
|
|
1703
|
+
const res = await fetch(`${url}/signals?limit=1`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
1704
|
+
if (res.ok) {
|
|
1705
|
+
const j = await res.json();
|
|
1706
|
+
if (Array.isArray(j && j.signals)) return "up";
|
|
1707
|
+
}
|
|
1708
|
+
} catch {
|
|
1709
|
+
}
|
|
1710
|
+
return "foreign";
|
|
1711
|
+
}
|
|
1712
|
+
var inFlight = null;
|
|
1713
|
+
function ensureBridge() {
|
|
1714
|
+
if (inFlight) return inFlight;
|
|
1715
|
+
inFlight = doEnsure().finally(() => {
|
|
1716
|
+
inFlight = null;
|
|
1717
|
+
});
|
|
1718
|
+
return inFlight;
|
|
1719
|
+
}
|
|
1720
|
+
async function doEnsure() {
|
|
1721
|
+
if (managedExternally()) return { up: false, reason: "external" };
|
|
1722
|
+
const first = await probe();
|
|
1723
|
+
if (first === "up") return { up: true };
|
|
1724
|
+
if (first === "foreign") return { up: false, reason: "foreign-port" };
|
|
1725
|
+
let pid;
|
|
1726
|
+
try {
|
|
1727
|
+
const home = skeletonHome();
|
|
1728
|
+
await mkdir2(home, { recursive: true });
|
|
1729
|
+
const child = spawn3(process.execPath, [bridgeScript()], {
|
|
1730
|
+
cwd: home,
|
|
1731
|
+
env: { ...process.env, BEECORK_SKELETON_HOME: home, BEECORK_SKELETON_PORT: String(port()) },
|
|
1732
|
+
detached: true,
|
|
1733
|
+
stdio: "ignore"
|
|
1734
|
+
// fire-and-forget; it logs to no one, which is fine
|
|
1735
|
+
});
|
|
1736
|
+
child.on("error", () => {
|
|
1737
|
+
});
|
|
1738
|
+
pid = child.pid;
|
|
1739
|
+
child.unref();
|
|
1740
|
+
} catch {
|
|
1741
|
+
return { up: false, reason: "spawn-failed" };
|
|
1742
|
+
}
|
|
1743
|
+
for (let i = 0; i < 10; i++) {
|
|
1744
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
1745
|
+
if (await probe() === "up") return { up: true, started: true, pid };
|
|
1746
|
+
}
|
|
1747
|
+
return { up: false, started: true, pid, reason: "spawn-failed" };
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1678
1750
|
// src/tools.ts
|
|
1679
1751
|
function runShell(command, opts) {
|
|
1680
1752
|
return new Promise((resolve2, reject) => {
|
|
1681
1753
|
const unix2 = process.platform !== "win32";
|
|
1682
|
-
const child =
|
|
1754
|
+
const child = spawn4(command, { shell: true, detached: unix2, stdio: ["ignore", "pipe", "pipe"] });
|
|
1683
1755
|
let stdout = "", stderr = "", outLen = 0, errLen = 0, timedOut = false, aborted = false;
|
|
1684
1756
|
let settled = false, exitCode = null;
|
|
1685
1757
|
const kill = () => {
|
|
@@ -1961,7 +2033,7 @@ async function walkTree(abs, prefix, items, cap) {
|
|
|
1961
2033
|
const e = kept[i];
|
|
1962
2034
|
const last = i === kept.length - 1;
|
|
1963
2035
|
items.push({ prefix: prefix + (last ? "\u2514\u2500 " : "\u251C\u2500 "), name: e.name + (e.isDirectory() ? "/" : ""), isDir: e.isDirectory() });
|
|
1964
|
-
if (e.isDirectory()) await walkTree(
|
|
2036
|
+
if (e.isDirectory()) await walkTree(join4(abs, e.name), prefix + (last ? " " : "\u2502 "), items, cap);
|
|
1965
2037
|
}
|
|
1966
2038
|
}
|
|
1967
2039
|
function parseRange(args, defLimit) {
|
|
@@ -1991,6 +2063,15 @@ async function readLineWindow(abs, offset1, limit) {
|
|
|
1991
2063
|
}
|
|
1992
2064
|
return { lines, startLine: start + 1, hasMore, empty: i === 0 };
|
|
1993
2065
|
}
|
|
2066
|
+
var EXTENSION_STEPS = `1. Load the extension: Chrome \u2192 chrome://extensions \u2192 turn on "Developer mode" \u2192 "Load unpacked" \u2192 select the beecork-extension/extension folder, and pin the icon.
|
|
2067
|
+
2. Click the icon (it auto-connects \u2014 no token to paste), tick "Capture enabled", open the app in a tab, and click "Pair this site".`;
|
|
2068
|
+
var DEV_SIGNALS_SETUP = `The browser link isn't connected yet.
|
|
2069
|
+
|
|
2070
|
+
This is "Beecork Skeleton" \u2014 a Chrome extension that streams the app's console errors and failed network requests to me, so I see what the browser sees instead of guessing. beecork runs the local inbox for you automatically; the only one-time step is loading the extension (local-only, no account):
|
|
2071
|
+
|
|
2072
|
+
` + EXTENSION_STEPS + `
|
|
2073
|
+
|
|
2074
|
+
Then call read_dev_signals again. Full step-by-step + troubleshooting is in the "browser-signals" skill.`;
|
|
1994
2075
|
var toolDefs = [
|
|
1995
2076
|
{
|
|
1996
2077
|
name: "read_file",
|
|
@@ -2384,9 +2465,9 @@ ${list}`;
|
|
|
2384
2465
|
},
|
|
2385
2466
|
run: async (args) => {
|
|
2386
2467
|
try {
|
|
2387
|
-
const dir =
|
|
2388
|
-
await
|
|
2389
|
-
const file =
|
|
2468
|
+
const dir = join4(process.cwd(), ".beecork");
|
|
2469
|
+
await mkdir3(dir, { recursive: true });
|
|
2470
|
+
const file = join4(dir, "memory.md");
|
|
2390
2471
|
const fact = String(args.fact).trim();
|
|
2391
2472
|
if (!fact) return 'Error: remember needs a non-empty "fact".';
|
|
2392
2473
|
let current = "";
|
|
@@ -2496,6 +2577,94 @@ ${skill.content}`;
|
|
|
2496
2577
|
}
|
|
2497
2578
|
return `No interactive user is available (headless run). Proceed with the most reasonable option for "${question}" and state the assumption you made.`;
|
|
2498
2579
|
}
|
|
2580
|
+
},
|
|
2581
|
+
{
|
|
2582
|
+
name: "read_dev_signals",
|
|
2583
|
+
description: "Read the browser's recent console errors and failed network requests for the user's app (localhost or production), captured live by the Beecork Skeleton extension \u2014 so you can SEE what's actually happening instead of guessing. Call this whenever the user reports a bug a browser would surface (blank page, broken button, failed save, a 500, a visual glitch). If it isn't connected yet it returns setup steps to relay to the user. Pull on demand; don't spam it.",
|
|
2584
|
+
parameters: {
|
|
2585
|
+
type: "object",
|
|
2586
|
+
properties: {
|
|
2587
|
+
kind: { type: "string", description: 'Filter: "network", "console", "pageError", "log", or "all" (default all).' },
|
|
2588
|
+
since_minutes: { type: "number", description: "Only signals from the last N minutes (optional)." },
|
|
2589
|
+
limit: { type: "number", description: "Max signals to return (default 30, max 200)." }
|
|
2590
|
+
},
|
|
2591
|
+
required: []
|
|
2592
|
+
},
|
|
2593
|
+
run: async (args, signal) => {
|
|
2594
|
+
const ens = await ensureBridge().catch(() => ({ up: false, reason: "spawn-failed" }));
|
|
2595
|
+
const base = skeletonUrl();
|
|
2596
|
+
const kind = args.kind ? String(args.kind) : "";
|
|
2597
|
+
const limit = Math.min(Math.max(Number(args.limit) || 30, 1), 200);
|
|
2598
|
+
const sinceMin = Number(args.since_minutes) || 0;
|
|
2599
|
+
const params = new URLSearchParams({ limit: String(limit) });
|
|
2600
|
+
if (kind && kind !== "all") params.set("kind", kind);
|
|
2601
|
+
if (sinceMin > 0) params.set("since", String(Date.now() - sinceMin * 6e4));
|
|
2602
|
+
const timeout = AbortSignal.timeout(Math.min(config.webTimeoutMs, 5e3));
|
|
2603
|
+
let data;
|
|
2604
|
+
try {
|
|
2605
|
+
const res = await fetch(`${base}/signals?${params}`, { signal: signal ? AbortSignal.any([signal, timeout]) : timeout });
|
|
2606
|
+
if (!res.ok) return `The browser link responded with HTTP ${res.status}. The inbox may be unhealthy \u2014 I'll try to start a fresh one on the next call.`;
|
|
2607
|
+
data = await res.json();
|
|
2608
|
+
} catch {
|
|
2609
|
+
if (ens.reason === "foreign-port") return `Another program is using the browser-link inbox port, so I couldn't start it. Free that port (or set BEECORK_DEV_SIGNALS_URL to a different inbox) and try again.`;
|
|
2610
|
+
return DEV_SIGNALS_SETUP;
|
|
2611
|
+
}
|
|
2612
|
+
const now = Date.now();
|
|
2613
|
+
const signals = (data.signals ?? []).filter((s) => s && s.kind !== "watch");
|
|
2614
|
+
if (signals.length === 0) {
|
|
2615
|
+
return `The inbox is running${ens.started ? " (I just started it)" : ""}, but no ${kind && kind !== "all" ? `"${kind}" ` : ""}signals were captured${sinceMin ? ` in the last ${sinceMin} min` : ""} yet.
|
|
2616
|
+
|
|
2617
|
+
If the Beecork Skeleton extension isn't loaded yet, connect it:
|
|
2618
|
+
${EXTENSION_STEPS}
|
|
2619
|
+
|
|
2620
|
+
Already connected? Reproduce the issue in the browser (or open the app), then call read_dev_signals again.`;
|
|
2621
|
+
}
|
|
2622
|
+
const ago2 = (ts) => ts ? `${Math.max(0, Math.round((now - ts) / 1e3))}s ago` : "";
|
|
2623
|
+
const lines = signals.map((s) => {
|
|
2624
|
+
if (s.kind === "network") return `[network] ${s.method || "GET"} ${s.url ?? ""} \u2192 ${s.status || s.text || "failed"} (${ago2(s.ts)})`;
|
|
2625
|
+
const text = String(s.text ?? "").replace(/\s+/g, " ").slice(0, 300);
|
|
2626
|
+
return `[${s.kind}] ${text}${s.url ? ` @ ${s.url}` : ""} (${ago2(s.ts)})`;
|
|
2627
|
+
});
|
|
2628
|
+
return `${signals.length} browser signal(s), newest last:
|
|
2629
|
+
${lines.join("\n")}`;
|
|
2630
|
+
}
|
|
2631
|
+
},
|
|
2632
|
+
{
|
|
2633
|
+
name: "watch_site",
|
|
2634
|
+
description: "Ask the Beecork Skeleton extension to start watching an APPROVED site's tab right now \u2014 use for an on-demand or production site you need to investigate (localhost/dev sites are watched automatically, so you don't need this for them). Only sites the user already approved are honored. After calling this, reproduce the issue (or open the site), then read_dev_signals.",
|
|
2635
|
+
parameters: {
|
|
2636
|
+
type: "object",
|
|
2637
|
+
properties: {
|
|
2638
|
+
url: { type: "string", description: "The site to watch, e.g. https://app.example.com (its origin is used)." },
|
|
2639
|
+
minutes: { type: "number", description: "How long to keep watching (default 10, max 120)." }
|
|
2640
|
+
},
|
|
2641
|
+
required: ["url"]
|
|
2642
|
+
},
|
|
2643
|
+
run: async (args, signal) => {
|
|
2644
|
+
await ensureBridge().catch(() => {
|
|
2645
|
+
});
|
|
2646
|
+
const base = skeletonUrl();
|
|
2647
|
+
let origin;
|
|
2648
|
+
try {
|
|
2649
|
+
origin = new URL(String(args.url ?? "")).origin;
|
|
2650
|
+
} catch {
|
|
2651
|
+
return `Error: "${args.url}" is not a valid URL.`;
|
|
2652
|
+
}
|
|
2653
|
+
const minutes = Math.min(Math.max(Number(args.minutes) || 10, 1), 120);
|
|
2654
|
+
const timeout = AbortSignal.timeout(Math.min(config.webTimeoutMs, 5e3));
|
|
2655
|
+
try {
|
|
2656
|
+
const res = await fetch(`${base}/request-watch`, {
|
|
2657
|
+
method: "POST",
|
|
2658
|
+
headers: { "Content-Type": "application/json" },
|
|
2659
|
+
body: JSON.stringify({ origin, ttlMs: minutes * 6e4 }),
|
|
2660
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout
|
|
2661
|
+
});
|
|
2662
|
+
if (!res.ok) return `The browser link responded with HTTP ${res.status}.`;
|
|
2663
|
+
} catch {
|
|
2664
|
+
return DEV_SIGNALS_SETUP;
|
|
2665
|
+
}
|
|
2666
|
+
return `Requested watching ${origin} for ${minutes} min. If the user has approved that site in the extension, it will start capturing shortly \u2014 have them reproduce the issue in a tab on ${origin} (or open it), then call read_dev_signals. If nothing shows up, the site isn't approved yet: ask the user to open it and click "Pair this site" in the Beecork Skeleton popup.`;
|
|
2667
|
+
}
|
|
2499
2668
|
}
|
|
2500
2669
|
];
|
|
2501
2670
|
var TOOLS = toolDefs.map((t) => ({
|
|
@@ -2859,32 +3028,32 @@ ${summary}` }, ...recent];
|
|
|
2859
3028
|
}
|
|
2860
3029
|
|
|
2861
3030
|
// src/memory.ts
|
|
2862
|
-
import { readFile as readFile4, writeFile as writeFile3, readdir as readdir3, mkdir as
|
|
2863
|
-
import { homedir as
|
|
2864
|
-
import { join as
|
|
3031
|
+
import { readFile as readFile4, writeFile as writeFile3, readdir as readdir3, mkdir as mkdir4, chmod as chmod2, rename as rename2, unlink } from "node:fs/promises";
|
|
3032
|
+
import { homedir as homedir6 } from "node:os";
|
|
3033
|
+
import { join as join5, dirname as dirname4 } from "node:path";
|
|
2865
3034
|
var BEECORK = ".beecork";
|
|
2866
3035
|
function ancestorDirs() {
|
|
2867
|
-
const home =
|
|
3036
|
+
const home = homedir6();
|
|
2868
3037
|
const dirs = [];
|
|
2869
3038
|
let dir = process.cwd();
|
|
2870
|
-
while (dir !== home && dir !==
|
|
3039
|
+
while (dir !== home && dir !== dirname4(dir)) {
|
|
2871
3040
|
dirs.push(dir);
|
|
2872
|
-
dir =
|
|
3041
|
+
dir = dirname4(dir);
|
|
2873
3042
|
}
|
|
2874
3043
|
return dirs.reverse();
|
|
2875
3044
|
}
|
|
2876
3045
|
function corkPaths() {
|
|
2877
|
-
return [
|
|
3046
|
+
return [join5(homedir6(), BEECORK, "cork.md"), ...ancestorDirs().map((d) => join5(d, "cork.md"))];
|
|
2878
3047
|
}
|
|
2879
3048
|
function beecorkPaths(name) {
|
|
2880
|
-
return [
|
|
3049
|
+
return [join5(homedir6(), BEECORK, name), ...ancestorDirs().map((d) => join5(d, BEECORK, name))];
|
|
2881
3050
|
}
|
|
2882
3051
|
function standardInstructionPaths() {
|
|
2883
|
-
return ancestorDirs().flatMap((d) => [
|
|
3052
|
+
return ancestorDirs().flatMap((d) => [join5(d, "AGENTS.md"), join5(d, "CLAUDE.md")]);
|
|
2884
3053
|
}
|
|
2885
3054
|
async function loadInstructions() {
|
|
2886
|
-
const home =
|
|
2887
|
-
const homeBeecork =
|
|
3055
|
+
const home = homedir6();
|
|
3056
|
+
const homeBeecork = join5(home, ".beecork");
|
|
2888
3057
|
const trusted = [];
|
|
2889
3058
|
const project = [];
|
|
2890
3059
|
const sources = [];
|
|
@@ -2937,14 +3106,14 @@ async function loadSettings() {
|
|
|
2937
3106
|
return { model, reasoningEffort, alwaysAllow, projectAlwaysAllowIgnored };
|
|
2938
3107
|
}
|
|
2939
3108
|
function userConfigPath() {
|
|
2940
|
-
return
|
|
3109
|
+
return join5(homedir6(), BEECORK, "config.json");
|
|
2941
3110
|
}
|
|
2942
3111
|
async function loadUserConfig() {
|
|
2943
3112
|
return await readJsonFile(userConfigPath()) ?? {};
|
|
2944
3113
|
}
|
|
2945
3114
|
async function saveUserConfig(patch) {
|
|
2946
3115
|
const file = userConfigPath();
|
|
2947
|
-
await
|
|
3116
|
+
await mkdir4(dirname4(file), { recursive: true });
|
|
2948
3117
|
const merged = { ...await loadUserConfig(), ...patch };
|
|
2949
3118
|
const tmp = `${file}.tmp`;
|
|
2950
3119
|
await writeFile3(tmp, JSON.stringify(merged, null, 2), { encoding: "utf8", mode: 384 });
|
|
@@ -2954,8 +3123,8 @@ async function saveUserConfig(patch) {
|
|
|
2954
3123
|
}
|
|
2955
3124
|
async function saveModelPreference(model) {
|
|
2956
3125
|
try {
|
|
2957
|
-
const file =
|
|
2958
|
-
await
|
|
3126
|
+
const file = join5(homedir6(), BEECORK, "settings.json");
|
|
3127
|
+
await mkdir4(dirname4(file), { recursive: true });
|
|
2959
3128
|
const current = await readJsonFile(file) ?? {};
|
|
2960
3129
|
await writeFile3(file, JSON.stringify({ ...current, model }, null, 2), "utf8");
|
|
2961
3130
|
} catch {
|
|
@@ -2963,19 +3132,19 @@ async function saveModelPreference(model) {
|
|
|
2963
3132
|
}
|
|
2964
3133
|
async function saveReasoningPreference(reasoningEffort) {
|
|
2965
3134
|
try {
|
|
2966
|
-
const file =
|
|
2967
|
-
await
|
|
3135
|
+
const file = join5(homedir6(), BEECORK, "settings.json");
|
|
3136
|
+
await mkdir4(dirname4(file), { recursive: true });
|
|
2968
3137
|
const current = await readJsonFile(file) ?? {};
|
|
2969
3138
|
await writeFile3(file, JSON.stringify({ ...current, reasoningEffort }, null, 2), "utf8");
|
|
2970
3139
|
} catch {
|
|
2971
3140
|
}
|
|
2972
3141
|
}
|
|
2973
|
-
var sessionsDir = () =>
|
|
3142
|
+
var sessionsDir = () => join5(process.cwd(), BEECORK, "sessions");
|
|
2974
3143
|
async function saveSession(messages) {
|
|
2975
3144
|
try {
|
|
2976
3145
|
const dir = sessionsDir();
|
|
2977
|
-
await
|
|
2978
|
-
const file =
|
|
3146
|
+
await mkdir4(dir, { recursive: true });
|
|
3147
|
+
const file = join5(dir, `${Date.now()}.json`);
|
|
2979
3148
|
const tmp = `${file}.tmp`;
|
|
2980
3149
|
await writeFile3(tmp, JSON.stringify(messages), "utf8");
|
|
2981
3150
|
await chmod2(tmp, 384).catch(() => {
|
|
@@ -2990,7 +3159,7 @@ var MAX_SESSIONS = 50;
|
|
|
2990
3159
|
async function pruneSessions(dir) {
|
|
2991
3160
|
const files = (await readdir3(dir)).filter((f) => f.endsWith(".json"));
|
|
2992
3161
|
if (files.length <= MAX_SESSIONS) return;
|
|
2993
|
-
for (const f of files.sort().slice(0, files.length - MAX_SESSIONS)) await unlink(
|
|
3162
|
+
for (const f of files.sort().slice(0, files.length - MAX_SESSIONS)) await unlink(join5(dir, f)).catch(() => {
|
|
2994
3163
|
});
|
|
2995
3164
|
}
|
|
2996
3165
|
function sanitizeSession(raw) {
|
|
@@ -3029,7 +3198,7 @@ function dropIncompleteToolTail(messages) {
|
|
|
3029
3198
|
}
|
|
3030
3199
|
async function readSession(file) {
|
|
3031
3200
|
try {
|
|
3032
|
-
const path =
|
|
3201
|
+
const path = join5(sessionsDir(), file);
|
|
3033
3202
|
const parsed = sanitizeSession(JSON.parse(await readFile4(path, "utf8")));
|
|
3034
3203
|
await chmod2(path, 384).catch(() => {
|
|
3035
3204
|
});
|
|
@@ -3070,7 +3239,7 @@ async function loadSession(file) {
|
|
|
3070
3239
|
return await readSession(file) ?? [];
|
|
3071
3240
|
}
|
|
3072
3241
|
function projectApprovalsPath() {
|
|
3073
|
-
return
|
|
3242
|
+
return join5(homedir6(), BEECORK, "project-approvals.json");
|
|
3074
3243
|
}
|
|
3075
3244
|
async function loadProjectApprovals() {
|
|
3076
3245
|
const all = await readJsonFile(projectApprovalsPath());
|
|
@@ -3080,7 +3249,7 @@ async function loadProjectApprovals() {
|
|
|
3080
3249
|
async function addProjectApproval(tool) {
|
|
3081
3250
|
try {
|
|
3082
3251
|
const file = projectApprovalsPath();
|
|
3083
|
-
await
|
|
3252
|
+
await mkdir4(dirname4(file), { recursive: true });
|
|
3084
3253
|
const all = await readJsonFile(file) ?? {};
|
|
3085
3254
|
const list = new Set(Array.isArray(all[projectRoot]) ? all[projectRoot] : []);
|
|
3086
3255
|
list.add(tool);
|
|
@@ -3798,7 +3967,7 @@ function stopChrome() {
|
|
|
3798
3967
|
var chromeEnabled = () => config.statuslineEnabled && !!process.stdout.isTTY;
|
|
3799
3968
|
|
|
3800
3969
|
// src/commands.ts
|
|
3801
|
-
import { writeFile as writeFile4, mkdir as
|
|
3970
|
+
import { writeFile as writeFile4, mkdir as mkdir5, chmod as chmod3 } from "node:fs/promises";
|
|
3802
3971
|
async function pick(opts) {
|
|
3803
3972
|
if (chromeEnabled()) {
|
|
3804
3973
|
const v = await chromePick(opts.items.map((it) => ({ label: it.label, hint: it.hint, value: it.value })), opts.initial, opts.title);
|
|
@@ -3925,7 +4094,7 @@ async function handleCommand(input, messages) {
|
|
|
3925
4094
|
} else if (cmd === "/good" || cmd === "/bad") {
|
|
3926
4095
|
const dir = cmd === "/bad" ? "eval/failures" : "eval/good";
|
|
3927
4096
|
try {
|
|
3928
|
-
await
|
|
4097
|
+
await mkdir5(dir, { recursive: true });
|
|
3929
4098
|
const file = `${dir}/${Date.now()}.json`;
|
|
3930
4099
|
await writeFile4(file, JSON.stringify({ rating: cmd.slice(1), model: state.model, messages }, null, 2), "utf8");
|
|
3931
4100
|
await chmod3(file, 384).catch(() => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "beecork",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.1",
|
|
4
4
|
"description": "beecork — a from-scratch CLI coding agent: multi-model (OpenRouter), BYOK, path-confined tools, built part by part.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
},
|
|
9
9
|
"main": "dist/index.js",
|
|
10
10
|
"files": [
|
|
11
|
-
"dist/"
|
|
11
|
+
"dist/",
|
|
12
|
+
"skills/",
|
|
13
|
+
"skeleton/"
|
|
12
14
|
],
|
|
13
15
|
"engines": {
|
|
14
16
|
"node": ">=20.12"
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Beecork Skeleton — local inbox (bridge). SHIPPED WITH beecork and auto-started
|
|
2
|
+
// by it (src/skeleton.ts), so the user never runs this by hand. It can still be run
|
|
3
|
+
// directly (`node skeleton/bridge.mjs`) for development.
|
|
4
|
+
//
|
|
5
|
+
// Receives signals POSTed by the extension, keeps a *bounded* rolling window of the
|
|
6
|
+
// most recent ones, and mirrors that window to dev-signals.jsonl (one JSON object per
|
|
7
|
+
// line). Writes are atomic (temp file + rename) so a reader never sees a half file.
|
|
8
|
+
// No dependencies — Node built-ins only.
|
|
9
|
+
//
|
|
10
|
+
// Lifecycle notes (what makes it safe for beecork to own):
|
|
11
|
+
// - Single instance: a second copy racing for the port exits(0) quietly on EADDRINUSE
|
|
12
|
+
// instead of crashing — so parallel beecork sessions share ONE inbox.
|
|
13
|
+
// - Self-tidying: exits itself after IDLE_MS with no traffic and no reads, so an
|
|
14
|
+
// auto-started bridge can't linger forever after the extension/browser is gone.
|
|
15
|
+
// - Fixed home: reads/writes under BEECORK_SKELETON_HOME (beecork points this at
|
|
16
|
+
// ~/.beecork/skeleton) instead of whatever cwd it was launched from.
|
|
17
|
+
|
|
18
|
+
import http from "node:http";
|
|
19
|
+
import { writeFile, readFile, rename } from "node:fs/promises";
|
|
20
|
+
import { resolve } from "node:path";
|
|
21
|
+
import { randomBytes } from "node:crypto";
|
|
22
|
+
|
|
23
|
+
const PORT = Number(process.env.BEECORK_SKELETON_PORT) || 8317;
|
|
24
|
+
const HOME = process.env.BEECORK_SKELETON_HOME || process.cwd();
|
|
25
|
+
const FILE = resolve(HOME, "dev-signals.jsonl");
|
|
26
|
+
const TMP = FILE + ".tmp";
|
|
27
|
+
const TOKEN_FILE = resolve(HOME, ".beecork-token");
|
|
28
|
+
const MAX = 1000; // keep at most this many recent signals
|
|
29
|
+
const IDLE_MS = 60 * 60 * 1000; // self-shutdown after an hour with zero activity
|
|
30
|
+
|
|
31
|
+
// Pairing token: the extension must present this to write. Blocks any other local
|
|
32
|
+
// process or malicious web page from POSTing fake signals to your agent. Generated
|
|
33
|
+
// once and persisted; the extension fetches it via /pair so the user never sees it.
|
|
34
|
+
let TOKEN;
|
|
35
|
+
try {
|
|
36
|
+
TOKEN = (await readFile(TOKEN_FILE, "utf8")).trim();
|
|
37
|
+
if (!TOKEN) throw new Error("empty token file");
|
|
38
|
+
} catch {
|
|
39
|
+
TOKEN = randomBytes(24).toString("hex");
|
|
40
|
+
await writeFile(TOKEN_FILE, TOKEN + "\n", { mode: 0o600 });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let buffer = []; // rolling in-memory window of recent signal lines; the file mirrors it.
|
|
44
|
+
const watchRequests = new Map(); // origin beecork asked to watch → expiry (ms)
|
|
45
|
+
let lastActivity = Date.now(); // reset on every request; drives idle self-shutdown
|
|
46
|
+
|
|
47
|
+
// Load any existing signals so a bridge restart keeps recent context.
|
|
48
|
+
try {
|
|
49
|
+
const prior = await readFile(FILE, "utf8");
|
|
50
|
+
buffer = prior.split("\n").filter(Boolean).slice(-MAX);
|
|
51
|
+
} catch {
|
|
52
|
+
/* no file yet — start empty */
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Persist the bounded window atomically. Writes are serialized through one chain so
|
|
56
|
+
// two concurrent ingests can't clobber the temp file mid-write.
|
|
57
|
+
let chain = Promise.resolve();
|
|
58
|
+
function persist() {
|
|
59
|
+
chain = chain
|
|
60
|
+
.then(async () => {
|
|
61
|
+
await writeFile(TMP, buffer.length ? buffer.join("\n") + "\n" : "");
|
|
62
|
+
await rename(TMP, FILE);
|
|
63
|
+
})
|
|
64
|
+
.catch((e) => console.error("[skeleton] persist failed:", e));
|
|
65
|
+
return chain;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A web page's fetch carries an http(s) Origin; refuse those so a page you visit can't
|
|
69
|
+
// read your captured app data or drive your extension. beecork's Node fetch has no web
|
|
70
|
+
// origin and passes. No CORS header is set on these routes on purpose.
|
|
71
|
+
const fromWebPage = (req) => /^https?:\/\//i.test(req.headers.origin || "");
|
|
72
|
+
|
|
73
|
+
const server = http.createServer((req, res) => {
|
|
74
|
+
lastActivity = Date.now();
|
|
75
|
+
|
|
76
|
+
// Liveness + identity marker: lets beecork tell OUR bridge apart from some other
|
|
77
|
+
// program that happens to hold the port, so it never spawns a duplicate or talks to
|
|
78
|
+
// a stranger. Non-sensitive, but web-origin-gated like everything else.
|
|
79
|
+
if (req.method === "GET" && req.url === "/health") {
|
|
80
|
+
if (fromWebPage(req)) return void res.writeHead(403).end("forbidden");
|
|
81
|
+
return void res
|
|
82
|
+
.writeHead(200, { "Content-Type": "application/json" })
|
|
83
|
+
.end(JSON.stringify({ skeleton: true, port: PORT, signals: buffer.length }));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Auto-pairing: hand the token to the extension so the user never touches it.
|
|
87
|
+
if (req.method === "GET" && req.url === "/pair") {
|
|
88
|
+
if (fromWebPage(req)) return void res.writeHead(403).end("forbidden");
|
|
89
|
+
return void res.writeHead(200, { "Content-Type": "text/plain" }).end(TOKEN);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// A distilled read for the agent: recent signals, filtered. ?kind=&since=<epoch-ms>&limit=
|
|
93
|
+
if (req.method === "GET" && req.url.startsWith("/signals")) {
|
|
94
|
+
if (fromWebPage(req)) return void res.writeHead(403).end("forbidden");
|
|
95
|
+
const q = new URL(req.url, "http://localhost").searchParams;
|
|
96
|
+
const kind = q.get("kind");
|
|
97
|
+
const since = Number(q.get("since")) || 0;
|
|
98
|
+
const limit = Math.min(Math.max(Number(q.get("limit")) || 50, 1), 1000);
|
|
99
|
+
let items = buffer.map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
100
|
+
if (kind && kind !== "all") items = items.filter((s) => s.kind === kind);
|
|
101
|
+
if (since) items = items.filter((s) => (s.ts || 0) >= since);
|
|
102
|
+
items = items.slice(-limit);
|
|
103
|
+
return void res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ signals: items }));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Reverse channel: beecork asks for a site to be watched. The extension honors it
|
|
107
|
+
// only for sites the user already approved.
|
|
108
|
+
if (req.method === "POST" && req.url === "/request-watch") {
|
|
109
|
+
if (fromWebPage(req)) return void res.writeHead(403).end("forbidden");
|
|
110
|
+
let body = "";
|
|
111
|
+
req.on("data", (c) => (body += c));
|
|
112
|
+
req.on("end", () => {
|
|
113
|
+
try {
|
|
114
|
+
const { origin: site, ttlMs } = JSON.parse(body || "{}");
|
|
115
|
+
if (site) watchRequests.set(String(site), Date.now() + (Number(ttlMs) || 10 * 60 * 1000));
|
|
116
|
+
res.writeHead(200).end("ok");
|
|
117
|
+
} catch {
|
|
118
|
+
res.writeHead(400).end("bad json");
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (req.method === "GET" && req.url.startsWith("/watch-requests")) {
|
|
124
|
+
if (fromWebPage(req)) return void res.writeHead(403).end("forbidden");
|
|
125
|
+
const now = Date.now();
|
|
126
|
+
for (const [site, exp] of watchRequests) if (exp < now) watchRequests.delete(site); // expire
|
|
127
|
+
return void res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ origins: [...watchRequests.keys()] }));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Permissive CORS so the extension's service worker can POST to /ingest.
|
|
131
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
132
|
+
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
|
133
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
134
|
+
if (req.method === "OPTIONS") return void res.writeHead(204).end();
|
|
135
|
+
|
|
136
|
+
if (req.method === "POST" && req.url === "/ingest") {
|
|
137
|
+
let body = "";
|
|
138
|
+
req.on("data", (c) => (body += c));
|
|
139
|
+
req.on("end", async () => {
|
|
140
|
+
try {
|
|
141
|
+
const signal = JSON.parse(body);
|
|
142
|
+
if (signal.token !== TOKEN) { res.writeHead(401).end("unauthorized"); return; } // token-gated
|
|
143
|
+
delete signal.token; // never persist the token itself
|
|
144
|
+
buffer.push(JSON.stringify(signal));
|
|
145
|
+
if (buffer.length > MAX) buffer.splice(0, buffer.length - MAX); // bound it
|
|
146
|
+
await persist();
|
|
147
|
+
console.log(`[skeleton] ${signal.kind}: ${String(signal.text || "").slice(0, 200)}`);
|
|
148
|
+
res.writeHead(200).end("ok");
|
|
149
|
+
} catch {
|
|
150
|
+
res.writeHead(400).end("bad json");
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
res.writeHead(404).end();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Single-instance: if another bridge already holds the port (a parallel beecork
|
|
159
|
+
// session started it), step aside quietly rather than crash.
|
|
160
|
+
server.on("error", (e) => {
|
|
161
|
+
if (e && e.code === "EADDRINUSE") process.exit(0);
|
|
162
|
+
console.error("[skeleton]", e);
|
|
163
|
+
process.exit(1);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Self-tidying: an auto-started bridge shouldn't outlive its usefulness. When the
|
|
167
|
+
// extension is loaded it polls every ~30s, so this only fires once the browser side
|
|
168
|
+
// is truly gone AND nothing has read for an hour.
|
|
169
|
+
const idle = setInterval(() => {
|
|
170
|
+
if (Date.now() - lastActivity > IDLE_MS) { server.close(); process.exit(0); }
|
|
171
|
+
}, 5 * 60 * 1000);
|
|
172
|
+
idle.unref(); // don't let the timer alone keep the process alive
|
|
173
|
+
|
|
174
|
+
server.listen(PORT, "127.0.0.1", () => {
|
|
175
|
+
console.log(`[skeleton] inbox listening on http://localhost:${PORT} (home: ${HOME})`);
|
|
176
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Set up / use the browser link so beecork sees the app's console + network errors (read_dev_signals)
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Browser signals — let beecork see the running app
|
|
6
|
+
|
|
7
|
+
You can read the user's app **console errors** and **failed network requests** straight
|
|
8
|
+
from the browser — on localhost *or* production, in their real logged-in session — with the
|
|
9
|
+
`read_dev_signals` tool. No copy-pasting errors. This is the **Beecork Skeleton** Chrome
|
|
10
|
+
extension plus a tiny local inbox (the "bridge").
|
|
11
|
+
|
|
12
|
+
## When to use `read_dev_signals`
|
|
13
|
+
|
|
14
|
+
Call it whenever the user reports something a browser would surface — a blank page, a broken
|
|
15
|
+
button, a form that won't submit, a save that 500s, a visual glitch. Instead of guessing, pull
|
|
16
|
+
the real errors:
|
|
17
|
+
|
|
18
|
+
- `read_dev_signals({ kind: "network" })` — failed requests (status ≥ 400 / network failures)
|
|
19
|
+
- `read_dev_signals({ since_minutes: 5 })` — everything captured in the last 5 minutes
|
|
20
|
+
- `read_dev_signals({})` — the most recent signals of any kind
|
|
21
|
+
|
|
22
|
+
Pull it **on demand** while debugging — don't call it in a loop or on every turn.
|
|
23
|
+
|
|
24
|
+
## Watching an on-demand / production site
|
|
25
|
+
|
|
26
|
+
Localhost/dev sites the user approved are watched automatically. A **production** (or
|
|
27
|
+
any "on-demand") approved site is idle until asked. To investigate one, call
|
|
28
|
+
`watch_site({ url })` — it asks the extension to watch that site for a while. Only sites
|
|
29
|
+
the user already approved are honored (beecork can't start watching a brand-new site on
|
|
30
|
+
its own). Then have the user reproduce the issue (or open the site) and call
|
|
31
|
+
`read_dev_signals`.
|
|
32
|
+
|
|
33
|
+
## If it says "not connected" — one-time setup
|
|
34
|
+
|
|
35
|
+
**beecork starts the local inbox itself** — the first time you call `read_dev_signals` or
|
|
36
|
+
`watch_site`, beecork auto-starts the bundled bridge (a `127.0.0.1:8317` inbox) in the
|
|
37
|
+
background and shares it across sessions. So there is **no bridge to run by hand**; the only
|
|
38
|
+
one-time step is loading the Chrome extension:
|
|
39
|
+
|
|
40
|
+
1. **Load the extension:** Chrome → `chrome://extensions` → enable **Developer mode** →
|
|
41
|
+
**Load unpacked** → select the `beecork-extension/extension` folder → pin the icon.
|
|
42
|
+
2. **Connect + approve:** click the icon (it auto-connects — no token to paste), tick
|
|
43
|
+
**Capture enabled**, open the app in a tab, and click **Pair this site**.
|
|
44
|
+
|
|
45
|
+
Then call `read_dev_signals` again. (If beecork reports the inbox port is held by another
|
|
46
|
+
program, free port 8317 or set `BEECORK_DEV_SIGNALS_URL` to a different inbox.)
|
|
47
|
+
|
|
48
|
+
## Empty result
|
|
49
|
+
|
|
50
|
+
If it connects but returns nothing, the watched tab just hasn't hit the error yet. Either ask
|
|
51
|
+
the user to reproduce it, or open the app yourself to trigger it — e.g.
|
|
52
|
+
`open -a "Google Chrome" http://localhost:3000` (macOS) — then reproduce the action and call
|
|
53
|
+
`read_dev_signals` again.
|
|
54
|
+
|
|
55
|
+
## How it stays safe
|
|
56
|
+
|
|
57
|
+
- **Approved sites only** — nothing is watched except sites the user approved in the popup.
|
|
58
|
+
- **Secrets redacted in the browser** — tokens, API keys, passwords, and Authorization values
|
|
59
|
+
are stripped *before* any signal leaves the tab, so they never reach the inbox.
|
|
60
|
+
- **Local + authenticated** — the inbox is `127.0.0.1` only, and only the extension can write to
|
|
61
|
+
it (an automatic local token; a web page you visit cannot).
|