diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b3e29b6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,80 @@ +# GoodBuddy Agent Guide + +## Scope + +These instructions apply to the entire repository. + +GoodBuddy is a secure, cross-platform Electron desktop assistant. It uses +Electron, React, TypeScript, Vite, Vitest, and SQLite. User-facing copy is +primarily Simplified Chinese. + +## Architecture + +- `src/main`: privileged Electron main process, runtimes, persistence, IPC, + knowledge, automation, and OS integration. +- `src/preload`: the narrow, typed bridge exposed to the renderer. +- `src/renderer`: React UI. It must not receive secrets or direct Node access. +- `src/shared`: schemas, contracts, presets, and IPC channel definitions shared + across process boundaries. +- `resources/skills`: bundled skills. +- `build`: packaging scripts and icons. +- `out` and `dist`: generated output. Change source files instead. + +Keep Electron security boundaries intact: + +- Preserve context isolation and sandboxing. +- Never enable renderer Node integration. +- Validate IPC input with shared Zod schemas and verify trusted senders. +- Expose only explicit preload methods. Do not pass raw Electron APIs. +- Keep API keys in the main process and encrypted settings store. +- Never log or return credentials, authorization headers, private documents, or + unredacted provider payloads. + +## Runtime Behavior + +- Ask and Plan modes must remain read-only at the runtime boundary. +- Execute mode may use tools only through the existing approval controls. +- Preserve cancellation, timeout, bounded-output, and shutdown behavior. +- Treat OpenCode and Continue as untrusted child runtimes. Preserve environment + allowlists, sandbox checks, and per-tool approval enforcement. +- A successful image-model configuration check does not prove generation works. + Only an actual generation request verifies the provider path. +- Do not fetch provider-returned image URLs. Accept and validate bounded inline + image data, then persist it as an artifact in the main process. + +## Data and Compatibility + +- Preserve existing user data and migrations. +- Do not weaken SQLite transaction, lifecycle, deduplication, or cleanup logic. +- Treat user workspaces and untracked files as user-owned. +- Keep Windows, macOS, Linux x64, and Linux arm64 behavior in mind. +- Do not hard-code machine-specific paths, credentials, or provider endpoints. + +## Implementation Conventions + +- Follow surrounding TypeScript and React patterns. +- Reuse installed libraries and shared contracts before adding dependencies. +- Keep changes focused. Do not add unrelated refactors or documentation. +- Add or update focused tests for behavioral changes and regressions. +- Avoid broad catches that erase HTTP status, cancellation, or provider error + context. Bound and redact any surfaced error details. +- Keep UI accessible with labels, keyboard behavior, semantic roles, and visible + focus states. + +## Validation + +Run all validators after source changes: + +```text +npm test +npm run typecheck +npm run lint +``` + +Run `npm run build` for production build changes. Use `npm run portable` only +when a current Windows portable package is requested. Gated runtime tests may +make paid or external calls, so run them only with explicit authorization. + +Before committing or pushing, inspect `git status`, `git diff`, and +`git diff --cached`. Do not commit secrets, local databases, logs, generated +credentials, or private user artifacts. diff --git a/icons/tools/make_icons.py b/icons/tools/make_icons.py new file mode 100644 index 0000000..9ddf778 --- /dev/null +++ b/icons/tools/make_icons.py @@ -0,0 +1,96 @@ +"""Crop the source renders to the icon tile and export PNG sizes plus .ico files.""" +from pathlib import Path + +import numpy as np +from PIL import Image + +ROOT = Path(__file__).resolve().parent.parent +SIZES = [1024, 512, 256, 128, 64, 48, 32, 16] +ICO_SIZES = [256, 128, 64, 48, 32, 16] +SS = 4 + +SOURCES = [ + ("light", ROOT / "ChatGPT_xQBGv24GYc.png"), + ("dark", ROOT / "ChatGPT_qPkaIrLGsm.png"), +] + + +def tile_mask(rgb): + a = rgb.astype(int) + r, g, b = a[..., 0], a[..., 1], a[..., 2] + # Anything that is not the near-white page background belongs to the artwork. + return (a.sum(2) < 748) | (np.abs(r - b) > 4) | (np.abs(g - b) > 4) + + +def tile_bbox(mask): + cols = mask.sum(0) + rows = mask.sum(1) + xs = np.where(cols > cols.max() * 0.35)[0] + ys = np.where(rows > rows.max() * 0.35)[0] + x0, x1, y0, y1 = xs.min(), xs.max(), ys.min(), ys.max() + # The renders carry a drop shadow below the tile, so trust the width and + # square the crop downward from the top edge. + side = x1 - x0 + 1 + return x0, x1, y0, y0 + side - 1 + + +def corner_radius(mask, x0, x1, y0, y1): + ests = [] + for d in range(10, int((y1 - y0) * 0.18)): + idx = np.where(mask[y0 + d, x0:x1 + 1])[0] + if not len(idx) or idx.min() <= 0: + continue + x = float(idx.min()) + ests.append((d + x) + np.sqrt(2.0 * d * x)) + return float(np.median(ests)) if ests else (x1 - x0) * 0.21 + + +def rounded_alpha(w, h, radius, size): + n = size * SS + ys, xs = np.mgrid[0:n, 0:n].astype(np.float64) + # Map supersampled pixel centres back onto the source tile grid. + px = (xs + 0.5) / n * w + py = (ys + 0.5) / n * h + r = radius + dx = np.clip(r - px, 0, None) + np.clip(px - (w - r), 0, None) + dy = np.clip(r - py, 0, None) + np.clip(py - (h - r), 0, None) + inside = (dx * dx + dy * dy) <= r * r + cov = inside.reshape(size, SS, size, SS).mean((1, 3)) + return (cov * 255).round().astype(np.uint8) + + +def build(name, path): + src = Image.open(path).convert("RGB") + mask = tile_mask(np.asarray(src)) + x0, x1, y0, y1 = tile_bbox(mask) + radius = corner_radius(mask, x0, x1, y0, y1) + tile = src.crop((x0, y0, x1 + 1, y1 + 1)) + w, h = tile.size + print(f"{name}: tile {w}x{h} radius {radius:.1f}") + + outdir = ROOT / "dist" / name + outdir.mkdir(parents=True, exist_ok=True) + + frames = {} + for size in SIZES: + img = tile.resize((size, size), Image.LANCZOS).convert("RGBA") + img.putalpha(Image.fromarray(rounded_alpha(w, h, radius, size), "L")) + img.save(outdir / f"icon-{size}.png", optimize=True) + frames[size] = img + + frames[1024].save(ROOT / "dist" / f"goodbuddy-{name}.ico", format="ICO", + sizes=[(s, s) for s in ICO_SIZES]) + return frames[512] + + +def main(): + previews = [build(n, p) for n, p in SOURCES] + gap = 32 + sheet = Image.new("RGBA", (512 * 2 + gap * 3, 512 + gap * 2), (128, 128, 128, 255)) + for i, img in enumerate(previews): + sheet.paste(img, (gap + i * (512 + gap), gap), img) + sheet.save(ROOT / "dist" / "preview.png") + + +if __name__ == "__main__": + main()