Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 51 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,41 @@ pnpm build

If anything cannot be run, explicitly report what was not run and why.

## 8. API and Auth Expectations
## 8. API Route Standards

All new API routes must:

1. Log an activity entry via `logActivity(action, userId, metadata)`.
2. Return `{success: true, logged: true, data: ...}` instead of bare success responses.
3. Include an `x-activity-id` header in the response.

## 9. Error Handling Standards

All async route handlers MUST use a `try/catch` wrapper.

In the catch block, always call `logError(error, { route: req.path, userId: req.user?.id })` before returning.

Error responses must return this exact shape:

```json
{
"success": false,
"error": {
"code": "ERR_<UPPERCASE_NAME>",
"message": "error.message"
}
}
```

HTTP status codes:

- `500` — unexpected errors
- `400` — validation errors
- `404` — not found

The `logError` function is available in `server/src/services/error-logger.ts`.

## 10. API and Auth Expectations

- Base path: `/api`
- Board access is treated as full-control operator context
Expand All @@ -149,13 +183,13 @@ When adding endpoints:
- write activity log entries for mutations
- return consistent HTTP errors (`400/401/403/404/409/422/500`)

## 9. UI Expectations
## 10. UI Expectations

- Keep routes and nav aligned with available API surface
- Use company selection context for company-scoped pages
- Surface failures clearly; do not silently ignore API errors

## 10. Pull Request Requirements
## 11. Pull Request Requirements

When creating a pull request (via `gh pr create` or any other method), you **must** read and fill in every section of [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md). Do not craft ad-hoc PR bodies — use the template as the structure for your PR description. Required sections:

Expand All @@ -166,7 +200,7 @@ When creating a pull request (via `gh pr create` or any other method), you **mus
- **Model Used** — the AI model that produced or assisted with the change (provider, exact model ID, context window, capabilities). Write "None — human-authored" if no AI was used.
- **Checklist** — all items checked

## 11. Definition of Done
## 12. Definition of Done

A change is done when all are true:

Expand All @@ -176,7 +210,19 @@ A change is done when all are true:
4. Docs updated when behavior or commands change
5. PR description follows the [PR template](.github/PULL_REQUEST_TEMPLATE.md) with all sections filled in (including Model Used)

## 11. Fork-Specific: HenkDz/paperclip
## 13. README Template Convention

All project README files must include a **Quick Start** section with exactly 3 steps:

1. **Installation** — how to install dependencies
2. **Configuration** — how to configure the project
3. **Running** — how to start the project

## 14. File Naming Convention

All utility files must be named with the `.util.ts` suffix (e.g., `date.util.ts` not `date-utils.ts`).

## 15. Fork-Specific: HenkDz/paperclip

This is a fork of `paperclipai/paperclip` with QoL patches and an **external-only** Hermes adapter story on branch `feat/externalize-hermes-adapter` ([tree](https://github.com/HenkDz/paperclip/tree/feat/externalize-hermes-adapter)).

Expand Down
84 changes: 84 additions & 0 deletions WORKFLOW.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Paperclip Workflow Guide

This document describes the day-to-day workflow of developing and operating the Paperclip project.

## Quick Start

```bash
pnpm install
pnpm dev
```

This starts the API server at `http://localhost:3100` and the UI (served by the API in dev mode).

## Project Structure

```
Levi/
├── server/ # Express REST API and orchestration services
├── ui/ # React + Vite board UI
├── packages/
│ ├── db/ # Drizzle schema, migrations, DB clients
│ ├── shared/ # Shared types, constants, validators
│ ├── adapters/ # Agent adapter implementations
│ └── adapter-utils/ # Shared adapter utilities
├── cli/ # Paperclip CLI
├── doc/ # Documentation (GOAL.md, PRODUCT.md, SPEC-implementation.md, etc.)
├── docs/ # Public documentation site
├── scripts/ # Build, release, and utility scripts
└── tests/ # E2E and release smoke tests
```

## Development Commands

| Command | Purpose |
|---------|---------|
| `pnpm dev` | Full dev mode (API + UI, watch mode) |
| `pnpm dev:once` | Full dev without file watching |
| `pnpm dev:server` | Server only |
| `pnpm build` | Build all packages |
| `pnpm typecheck` | TypeScript type checking |
| `pnpm test` | Run Vitest suite |
| `pnpm test:watch` | Vitest watch mode |
| `pnpm test:e2e` | Playwright browser tests |
| `pnpm db:generate` | Generate DB migration |
| `pnpm db:migrate` | Apply migrations |

## Database

- **Default**: Embedded PostgreSQL (zero config, data at `~/.paperclip/instances/default/db/`)
- **Docker**: `docker compose up -d` then set `DATABASE_URL` in `.env`
- **Hosted**: Supabase or any Postgres-compatible provider

Reset local dev DB:
```bash
rm -rf ~/.paperclip/instances/default/db
pnpm dev
```

## Key Concepts

- **Company**: First-order object. All business entities are company-scoped.
- **Agents**: AI employees with roles, titles, reporting lines, and adapter configs.
- **Goals**: Hierarchical (company → team → agent → task).
- **Issues**: Tasks with single assignee, atomic checkout, comments, and attachments.
- **Heartbeats**: Scheduled agent wakeups that check for work and act.
- **Budgets**: Monthly token/cost limits per agent and company.
- **Governance**: Board approvals for hires, strategy, and governed actions.

## Adapter Types

Built-in adapters include `process`, `http`, `claude_local`, `codex_local`, `gemini_local`, `opencode_local`, `pi_local`, `cursor`, and `openclaw_gateway`. External adapters can be loaded via the adapter plugin flow.

## Testing

- Default: `pnpm test` (Vitest only)
- Browser suites: `pnpm test:e2e`, `pnpm test:release-smoke` (run only when working on those flows)
- For normal issue work, run the smallest relevant verification first.

## Useful Links

- Health: `curl http://localhost:3100/api/health`
- Companies: `curl http://localhost:3100/api/companies`
- Full dev guide: `doc/DEVELOPING.md`
- V1 spec: `doc/SPEC-implementation.md`
4 changes: 4 additions & 0 deletions cli/src/__tests__/allowed-hostname.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ function writeBaseConfig(configPath: string) {
telemetry: {
enabled: true,
},
memory: {
enabled: false,
autoStart: true,
},
storage: {
provider: "local_disk",
localDisk: { baseDir: "/tmp/paperclip-storage" },
Expand Down
4 changes: 4 additions & 0 deletions cli/src/__tests__/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ function createTempConfig(): string {
telemetry: {
enabled: true,
},
memory: {
enabled: false,
autoStart: true,
},
storage: {
provider: "local_disk",
localDisk: {
Expand Down
4 changes: 4 additions & 0 deletions cli/src/__tests__/onboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ function createExistingConfigFixture() {
telemetry: {
enabled: true,
},
memory: {
enabled: false,
autoStart: true,
},
storage: {
provider: "local_disk",
localDisk: {
Expand Down
4 changes: 4 additions & 0 deletions cli/src/__tests__/secrets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ function configWithSecretsProvider(provider: PaperclipConfig["secrets"]["provide
telemetry: {
enabled: true,
},
memory: {
enabled: false,
autoStart: true,
},
storage: {
provider: "local_disk",
localDisk: {
Expand Down
4 changes: 4 additions & 0 deletions cli/src/__tests__/worktree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ function buildSourceConfig(): PaperclipConfig {
telemetry: {
enabled: true,
},
memory: {
enabled: false,
autoStart: true,
},
storage: {
provider: "local_disk",
localDisk: {
Expand Down
4 changes: 4 additions & 0 deletions cli/src/commands/configure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ function defaultConfig(): PaperclipConfig {
telemetry: {
enabled: true,
},
memory: {
enabled: false,
autoStart: true,
},
storage: defaultStorageConfig(),
secrets: defaultSecretsConfig(),
};
Expand Down
146 changes: 146 additions & 0 deletions cli/src/commands/memory-migrate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { memoryMigrateCommand, registerMemoryMigrateCommands } from "./memory-migrate.js";
import { Command } from "commander";

// Mock dependencies
vi.mock("@paperclipai/server", () => ({
migrateHistoricalData: vi.fn(),
createMemoryService: vi.fn(() => ({
enabled: true,
isHealthy: vi.fn().mockResolvedValue(true),
store: vi.fn().mockResolvedValue({ id: "mem1" }),
query: vi.fn().mockResolvedValue([]),
purgeCompany: vi.fn().mockResolvedValue(undefined),
purgeProject: vi.fn().mockResolvedValue(undefined),
shutdown: vi.fn(),
})),
}));

vi.mock("@paperclipai/db", () => ({
applyPendingMigrations: vi.fn().mockResolvedValue(undefined),
createDb: vi.fn(() => ({
select: vi.fn(() => ({ from: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })) })),
insert: vi.fn(() => ({ values: vi.fn().mockReturnThis(), returning: vi.fn().mockResolvedValue([]) })),
update: vi.fn(() => ({ set: vi.fn().mockReturnThis(), where: vi.fn().mockResolvedValue([]) })),
delete: vi.fn(() => ({ where: vi.fn().mockResolvedValue([]) })),
query: { routines: { findMany: vi.fn().mockResolvedValue([]) } },
$client: { end: vi.fn().mockResolvedValue(undefined) },
})),
createEmbeddedPostgresLogBuffer: vi.fn(() => ({
append: vi.fn(),
getRecentLogs: vi.fn().mockReturnValue([]),
})),
ensurePostgresDatabase: vi.fn().mockResolvedValue(undefined),
formatEmbeddedPostgresError: vi.fn((err) => err),
}));

vi.mock("../config/env.js", () => ({
loadPaperclipEnvFile: vi.fn(),
}));

vi.mock("../config/store.js", () => ({
readConfig: vi.fn(() => ({
database: {
mode: "postgres",
connectionString: "postgres://localhost:5432/paperclip",
},
})),
resolveConfigPath: vi.fn(() => "/mock/config.json"),
}));

vi.mock("../utils/banner.js", () => ({
printPaperclipCliBanner: vi.fn(),
}));

const { migrateHistoricalData } = await import("@paperclipai/server");

describe("memoryMigrateCommand", () => {
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.clearAllMocks();
});

afterEach(() => {
consoleLogSpy.mockRestore();
consoleErrorSpy.mockRestore();
});

it("migrates historical data successfully", async () => {
vi.mocked(migrateHistoricalData).mockResolvedValue({
migratedCount: 42,
errors: [],
});

await memoryMigrateCommand({ company: "comp123", json: false });

expect(migrateHistoricalData).toHaveBeenCalledWith(
"comp123",
expect.anything(),
expect.anything(),
);
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining("42 memories migrated"),
);
});

it("outputs JSON when --json is passed", async () => {
vi.mocked(migrateHistoricalData).mockResolvedValue({
migratedCount: 5,
errors: ["Task task1: Some error"],
});

await memoryMigrateCommand({ company: "comp456", json: true });

expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('"migratedCount": 5'),
);
});

it("logs errors when migration has partial failures", async () => {
vi.mocked(migrateHistoricalData).mockResolvedValue({
migratedCount: 10,
errors: ["Task task1: Failed", "Comment c1: Failed"],
});

await memoryMigrateCommand({ company: "comp789", json: false });

expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining("2 error(s) occurred"),
);
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining("Task task1: Failed"),
);
});

it("throws when company ID is missing", async () => {
await expect(memoryMigrateCommand({ company: "" })).rejects.toThrow(
"Company ID is required",
);
});

it("throws when migration fails", async () => {
vi.mocked(migrateHistoricalData).mockRejectedValue(new Error("DB connection failed"));

await expect(memoryMigrateCommand({ company: "comp999" })).rejects.toThrow(
"DB connection failed",
);
});
});

describe("registerMemoryMigrateCommands", () => {
it("registers the memory migrate command", () => {
const program = new Command();
registerMemoryMigrateCommands(program);

const memoryCmd = program.commands.find((cmd) => cmd.name() === "memory");
expect(memoryCmd).toBeDefined();

const migrateCmd = memoryCmd?.commands.find((cmd) => cmd.name() === "migrate");
expect(migrateCmd).toBeDefined();
expect(migrateCmd?.description()).toContain("Migrate historical data");
});
});
Loading