mirror of
https://github.com/paperclipai/paperclip
synced 2026-05-06 15:12:03 +02:00
Compare commits
126 Commits
fix/embedd
...
paperclip-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b6ecf2377 | ||
|
|
084bfd3d99 | ||
|
|
ccb6729ec8 | ||
|
|
4244047d4d | ||
|
|
34ec40211e | ||
|
|
52b12784a0 | ||
|
|
3bffe3e479 | ||
|
|
c5cc191a08 | ||
|
|
16ab8c8303 | ||
|
|
2daa35cd3a | ||
|
|
597c4b1d45 | ||
|
|
6f931b8405 | ||
|
|
41e03bae61 | ||
|
|
d7f45eac14 | ||
|
|
94112b324c | ||
|
|
fe0d7d029a | ||
|
|
aea133ff9f | ||
|
|
c94132bc7e | ||
|
|
8468d347be | ||
|
|
61fd5486e8 | ||
|
|
675421f3a9 | ||
|
|
2162289bf3 | ||
|
|
bfaa4b4bdc | ||
|
|
872807a6f8 | ||
|
|
f482433ddf | ||
|
|
825d2b4759 | ||
|
|
6c7ebaeb59 | ||
|
|
6d65800173 | ||
|
|
0d2380b7b1 | ||
|
|
ec261e9c7c | ||
|
|
5d52ce2e5e | ||
|
|
811e2b9909 | ||
|
|
8985ddaeed | ||
|
|
2dbb31ef3c | ||
|
|
648ee37a17 | ||
|
|
98e73acc3b | ||
|
|
0fb85e5729 | ||
|
|
7e3a04c76c | ||
|
|
e219761d95 | ||
|
|
0afd5d5630 | ||
|
|
4f8df1804d | ||
|
|
d0677dcd91 | ||
|
|
bc5d650248 | ||
|
|
2e3a0d027e | ||
|
|
b92f234d88 | ||
|
|
0f831e09c1 | ||
|
|
a6c7e09e2a | ||
|
|
30e2914424 | ||
|
|
6b17f7caa8 | ||
|
|
2dc3b4df24 | ||
|
|
b13c530024 | ||
|
|
0851e81b47 | ||
|
|
325fcf8505 | ||
|
|
4cfbeaba9d | ||
|
|
0605c9f229 | ||
|
|
22b8e90ba6 | ||
|
|
7c4b02f02b | ||
|
|
ff4f326341 | ||
|
|
dcd8a47d4f | ||
|
|
eafb5b8fd9 | ||
|
|
30888759f2 | ||
|
|
193a987513 | ||
|
|
cb5d7e76fb | ||
|
|
bc12f08c66 | ||
|
|
a7a64f11be | ||
|
|
31e6e30fe3 | ||
|
|
ad7bf4288a | ||
|
|
16dfcb56a4 | ||
|
|
924762c073 | ||
|
|
abb70ca5c5 | ||
|
|
1e3a485408 | ||
|
|
07d13e1738 | ||
|
|
c8cd950a03 | ||
|
|
501ab4ffa9 | ||
|
|
d671a59306 | ||
|
|
6fa1dd2197 | ||
|
|
93faf6d361 | ||
|
|
eb0a74384e | ||
|
|
ab41fdbaee | ||
|
|
45998aa9a0 | ||
|
|
303c00b61b | ||
|
|
12ccfc2c9a | ||
|
|
80cdbdbd47 | ||
|
|
bcce5b7ec2 | ||
|
|
8eacc9c697 | ||
|
|
db81a06386 | ||
|
|
626a8f1976 | ||
|
|
aa799bba4c | ||
|
|
aaadbdc144 | ||
|
|
a393db78b4 | ||
|
|
c1430e7b06 | ||
|
|
7e288d20fc | ||
|
|
528505a04a | ||
|
|
e2a0347c6d | ||
|
|
cce9941464 | ||
|
|
d51c4b1a4c | ||
|
|
3b0d9a93f4 | ||
|
|
41eb8e51e3 | ||
|
|
cdebf7b538 | ||
|
|
32ab4f8e47 | ||
|
|
6365e03731 | ||
|
|
2b9de934e3 | ||
|
|
4a368f54d5 | ||
|
|
7d1748b3a7 | ||
|
|
2246d5f1eb | ||
|
|
575a2fd83f | ||
|
|
c9259bbec0 | ||
|
|
f3c18db7dd | ||
|
|
43baf709dd | ||
|
|
0f3e9937f6 | ||
|
|
e24a116943 | ||
|
|
e84c0e8df2 | ||
|
|
4e354ad00d | ||
|
|
83381f9c12 | ||
|
|
f7e1952a55 | ||
|
|
cf77ff927f | ||
|
|
fc8b5e3956 | ||
|
|
ed16d30afc | ||
|
|
402cef66e9 | ||
|
|
13c2ecd1d0 | ||
|
|
a2b7611d8d | ||
|
|
63c62e3ada | ||
|
|
964e04369a | ||
|
|
873535fbf0 | ||
|
|
87c0bf9cdf | ||
|
|
97d628d784 |
201
.agents/skills/doc-maintenance/SKILL.md
Normal file
201
.agents/skills/doc-maintenance/SKILL.md
Normal file
@@ -0,0 +1,201 @@
|
||||
---
|
||||
name: doc-maintenance
|
||||
description: >
|
||||
Audit top-level documentation (README, SPEC, PRODUCT) against recent git
|
||||
history to find drift — shipped features missing from docs or features
|
||||
listed as upcoming that already landed. Proposes minimal edits, creates
|
||||
a branch, and opens a PR. Use when asked to review docs for accuracy,
|
||||
after major feature merges, or on a periodic schedule.
|
||||
---
|
||||
|
||||
# Doc Maintenance Skill
|
||||
|
||||
Detect documentation drift and fix it via PR — no rewrites, no churn.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Periodic doc review (e.g. weekly or after releases)
|
||||
- After major feature merges
|
||||
- When asked "are our docs up to date?"
|
||||
- When asked to audit README / SPEC / PRODUCT accuracy
|
||||
|
||||
## Target Documents
|
||||
|
||||
| Document | Path | What matters |
|
||||
|----------|------|-------------|
|
||||
| README | `README.md` | Features table, roadmap, quickstart, "what is" accuracy, "works with" table |
|
||||
| SPEC | `doc/SPEC.md` | No false "not supported" claims, major model/schema accuracy |
|
||||
| PRODUCT | `doc/PRODUCT.md` | Core concepts, feature list, principles accuracy |
|
||||
|
||||
Out of scope: DEVELOPING.md, DATABASE.md, CLI.md, doc/plans/, skill files,
|
||||
release notes. These are dev-facing or ephemeral — lower risk of user-facing
|
||||
confusion.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Detect what changed
|
||||
|
||||
Find the last review cursor:
|
||||
|
||||
```bash
|
||||
# Read the last-reviewed commit SHA
|
||||
CURSOR_FILE=".doc-review-cursor"
|
||||
if [ -f "$CURSOR_FILE" ]; then
|
||||
LAST_SHA=$(cat "$CURSOR_FILE" | head -1)
|
||||
else
|
||||
# First run: look back 60 days
|
||||
LAST_SHA=$(git log --format="%H" --after="60 days ago" --reverse | head -1)
|
||||
fi
|
||||
```
|
||||
|
||||
Then gather commits since the cursor:
|
||||
|
||||
```bash
|
||||
git log "$LAST_SHA"..HEAD --oneline --no-merges
|
||||
```
|
||||
|
||||
### Step 2 — Classify changes
|
||||
|
||||
Scan commit messages and changed files. Categorize into:
|
||||
|
||||
- **Feature** — new capabilities (keywords: `feat`, `add`, `implement`, `support`)
|
||||
- **Breaking** — removed/renamed things (keywords: `remove`, `breaking`, `drop`, `rename`)
|
||||
- **Structural** — new directories, config changes, new adapters, new CLI commands
|
||||
|
||||
**Ignore:** refactors, test-only changes, CI config, dependency bumps, doc-only
|
||||
changes, style/formatting commits. These don't affect doc accuracy.
|
||||
|
||||
For borderline cases, check the actual diff — a commit titled "refactor: X"
|
||||
that adds a new public API is a feature.
|
||||
|
||||
### Step 3 — Build a change summary
|
||||
|
||||
Produce a concise list like:
|
||||
|
||||
```
|
||||
Since last review (<sha>, <date>):
|
||||
- FEATURE: Plugin system merged (runtime, SDK, CLI, slots, event bridge)
|
||||
- FEATURE: Project archiving added
|
||||
- BREAKING: Removed legacy webhook adapter
|
||||
- STRUCTURAL: New .agents/skills/ directory convention
|
||||
```
|
||||
|
||||
If there are no notable changes, skip to Step 7 (update cursor and exit).
|
||||
|
||||
### Step 4 — Audit each target doc
|
||||
|
||||
For each target document, read it fully and cross-reference against the change
|
||||
summary. Check for:
|
||||
|
||||
1. **False negatives** — major shipped features not mentioned at all
|
||||
2. **False positives** — features listed as "coming soon" / "roadmap" / "planned"
|
||||
/ "not supported" / "TBD" that already shipped
|
||||
3. **Quickstart accuracy** — install commands, prereqs, and startup instructions
|
||||
still correct (README only)
|
||||
4. **Feature table accuracy** — does the features section reflect current
|
||||
capabilities? (README only)
|
||||
5. **Works-with accuracy** — are supported adapters/integrations listed correctly?
|
||||
|
||||
Use `references/audit-checklist.md` as the structured checklist.
|
||||
Use `references/section-map.md` to know where to look for each feature area.
|
||||
|
||||
### Step 5 — Create branch and apply minimal edits
|
||||
|
||||
```bash
|
||||
# Create a branch for the doc updates
|
||||
BRANCH="docs/maintenance-$(date +%Y%m%d)"
|
||||
git checkout -b "$BRANCH"
|
||||
```
|
||||
|
||||
Apply **only** the edits needed to fix drift. Rules:
|
||||
|
||||
- **Minimal patches only.** Fix inaccuracies, don't rewrite sections.
|
||||
- **Preserve voice and style.** Match the existing tone of each document.
|
||||
- **No cosmetic changes.** Don't fix typos, reformat tables, or reorganize
|
||||
sections unless they're part of a factual fix.
|
||||
- **No new sections.** If a feature needs a whole new section, note it in the
|
||||
PR description as a follow-up — don't add it in a maintenance pass.
|
||||
- **Roadmap items:** Move shipped features out of Roadmap. Add a brief mention
|
||||
in the appropriate existing section if there isn't one already. Don't add
|
||||
long descriptions.
|
||||
|
||||
### Step 6 — Open a PR
|
||||
|
||||
Commit the changes and open a PR:
|
||||
|
||||
```bash
|
||||
git add README.md doc/SPEC.md doc/PRODUCT.md .doc-review-cursor
|
||||
git commit -m "docs: update documentation for accuracy
|
||||
|
||||
- [list each fix briefly]
|
||||
|
||||
Co-Authored-By: Paperclip <noreply@paperclip.ing>"
|
||||
|
||||
git push -u origin "$BRANCH"
|
||||
|
||||
gh pr create \
|
||||
--title "docs: periodic documentation accuracy update" \
|
||||
--body "$(cat <<'EOF'
|
||||
## Summary
|
||||
Automated doc maintenance pass. Fixes documentation drift detected since
|
||||
last review.
|
||||
|
||||
### Changes
|
||||
- [list each fix]
|
||||
|
||||
### Change summary (since last review)
|
||||
- [list notable code changes that triggered doc updates]
|
||||
|
||||
## Review notes
|
||||
- Only factual accuracy fixes — no style/cosmetic changes
|
||||
- Preserves existing voice and structure
|
||||
- Larger doc additions (new sections, tutorials) noted as follow-ups
|
||||
|
||||
🤖 Generated by doc-maintenance skill
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
### Step 7 — Update the cursor
|
||||
|
||||
After a successful audit (whether or not edits were needed), update the cursor:
|
||||
|
||||
```bash
|
||||
git rev-parse HEAD > .doc-review-cursor
|
||||
```
|
||||
|
||||
If edits were made, this is already committed in the PR branch. If no edits
|
||||
were needed, commit the cursor update to the current branch.
|
||||
|
||||
## Change Classification Rules
|
||||
|
||||
| Signal | Category | Doc update needed? |
|
||||
|--------|----------|-------------------|
|
||||
| `feat:`, `add`, `implement`, `support` in message | Feature | Yes if user-facing |
|
||||
| `remove`, `drop`, `breaking`, `!:` in message | Breaking | Yes |
|
||||
| New top-level directory or config file | Structural | Maybe |
|
||||
| `fix:`, `bugfix` | Fix | No (unless it changes behavior described in docs) |
|
||||
| `refactor:`, `chore:`, `ci:`, `test:` | Maintenance | No |
|
||||
| `docs:` | Doc change | No (already handled) |
|
||||
| Dependency bumps only | Maintenance | No |
|
||||
|
||||
## Patch Style Guide
|
||||
|
||||
- Fix the fact, not the prose
|
||||
- If removing a roadmap item, don't leave a gap — remove the bullet cleanly
|
||||
- If adding a feature mention, match the format of surrounding entries
|
||||
(e.g. if features are in a table, add a table row)
|
||||
- Keep README changes especially minimal — it shouldn't churn often
|
||||
- For SPEC/PRODUCT, prefer updating existing statements over adding new ones
|
||||
(e.g. change "not supported in V1" to "supported via X" rather than adding
|
||||
a new section)
|
||||
|
||||
## Output
|
||||
|
||||
When the skill completes, report:
|
||||
|
||||
- How many commits were scanned
|
||||
- How many notable changes were found
|
||||
- How many doc edits were made (and to which files)
|
||||
- PR link (if edits were made)
|
||||
- Any follow-up items that need larger doc work
|
||||
85
.agents/skills/doc-maintenance/references/audit-checklist.md
Normal file
85
.agents/skills/doc-maintenance/references/audit-checklist.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Doc Maintenance Audit Checklist
|
||||
|
||||
Use this checklist when auditing each target document. For each item, compare
|
||||
against the change summary from git history.
|
||||
|
||||
## README.md
|
||||
|
||||
### Features table
|
||||
- [ ] Each feature card reflects a shipped capability
|
||||
- [ ] No feature cards for things that don't exist yet
|
||||
- [ ] No major shipped features missing from the table
|
||||
|
||||
### Roadmap
|
||||
- [ ] Nothing listed as "planned" or "coming soon" that already shipped
|
||||
- [ ] No removed/cancelled items still listed
|
||||
- [ ] Items reflect current priorities (cross-check with recent PRs)
|
||||
|
||||
### Quickstart
|
||||
- [ ] `npx paperclipai onboard` command is correct
|
||||
- [ ] Manual install steps are accurate (clone URL, commands)
|
||||
- [ ] Prerequisites (Node version, pnpm version) are current
|
||||
- [ ] Server URL and port are correct
|
||||
|
||||
### "What is Paperclip" section
|
||||
- [ ] High-level description is accurate
|
||||
- [ ] Step table (Define goal / Hire team / Approve and run) is correct
|
||||
|
||||
### "Works with" table
|
||||
- [ ] All supported adapters/runtimes are listed
|
||||
- [ ] No removed adapters still listed
|
||||
- [ ] Logos and labels match current adapter names
|
||||
|
||||
### "Paperclip is right for you if"
|
||||
- [ ] Use cases are still accurate
|
||||
- [ ] No claims about capabilities that don't exist
|
||||
|
||||
### "Why Paperclip is special"
|
||||
- [ ] Technical claims are accurate (atomic execution, governance, etc.)
|
||||
- [ ] No features listed that were removed or significantly changed
|
||||
|
||||
### FAQ
|
||||
- [ ] Answers are still correct
|
||||
- [ ] No references to removed features or outdated behavior
|
||||
|
||||
### Development section
|
||||
- [ ] Commands are accurate (`pnpm dev`, `pnpm build`, etc.)
|
||||
- [ ] Link to DEVELOPING.md is correct
|
||||
|
||||
## doc/SPEC.md
|
||||
|
||||
### Company Model
|
||||
- [ ] Fields match current schema
|
||||
- [ ] Governance model description is accurate
|
||||
|
||||
### Agent Model
|
||||
- [ ] Adapter types match what's actually supported
|
||||
- [ ] Agent configuration description is accurate
|
||||
- [ ] No features described as "not supported" or "not V1" that shipped
|
||||
|
||||
### Task Model
|
||||
- [ ] Task hierarchy description is accurate
|
||||
- [ ] Status values match current implementation
|
||||
|
||||
### Extensions / Plugins
|
||||
- [ ] If plugins are shipped, no "not in V1" or "future" language
|
||||
- [ ] Plugin model description matches implementation
|
||||
|
||||
### Open Questions
|
||||
- [ ] Resolved questions removed or updated
|
||||
- [ ] No "TBD" items that have been decided
|
||||
|
||||
## doc/PRODUCT.md
|
||||
|
||||
### Core Concepts
|
||||
- [ ] Company, Employees, Task Management descriptions accurate
|
||||
- [ ] Agent Execution modes described correctly
|
||||
- [ ] No missing major concepts
|
||||
|
||||
### Principles
|
||||
- [ ] Principles haven't been contradicted by shipped features
|
||||
- [ ] No principles referencing removed capabilities
|
||||
|
||||
### User Flow
|
||||
- [ ] Dream scenario still reflects actual onboarding
|
||||
- [ ] Steps are achievable with current features
|
||||
22
.agents/skills/doc-maintenance/references/section-map.md
Normal file
22
.agents/skills/doc-maintenance/references/section-map.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Section Map
|
||||
|
||||
Maps feature areas to specific document sections so the skill knows where to
|
||||
look when a feature ships or changes.
|
||||
|
||||
| Feature Area | README Section | SPEC Section | PRODUCT Section |
|
||||
|-------------|---------------|-------------|----------------|
|
||||
| Plugins / Extensions | Features table, Roadmap | Extensions, Agent Model | Core Concepts |
|
||||
| Adapters (new runtimes) | "Works with" table, FAQ | Agent Model, Agent Configuration | Employees & Agents, Agent Execution |
|
||||
| Governance / Approvals | Features table, "Why special" | Board Governance, Board Approval Gates | Principles |
|
||||
| Budget / Cost Control | Features table, "Why special" | Budget Delegation | Company (revenue & expenses) |
|
||||
| Task Management | Features table | Task Model | Task Management |
|
||||
| Org Chart / Hierarchy | Features table | Agent Model (reporting) | Employees & Agents |
|
||||
| Multi-Company | Features table, FAQ | Company Model | Company |
|
||||
| Heartbeats | Features table, FAQ | Agent Execution | Agent Execution |
|
||||
| CLI Commands | Development section | — | — |
|
||||
| Onboarding / Quickstart | Quickstart, FAQ | — | User Flow |
|
||||
| Skills / Skill Injection | "Why special" | — | — |
|
||||
| Company Templates | "Why special", Roadmap (ClipMart) | — | — |
|
||||
| Mobile / UI | Features table | — | — |
|
||||
| Project Archiving | — | — | — |
|
||||
| OpenClaw Integration | "Works with" table, FAQ | Agent Model | Agent Execution |
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -38,6 +38,10 @@ tmp/
|
||||
.claude/settings.local.json
|
||||
.paperclip-local/
|
||||
|
||||
# Doc maintenance cursor
|
||||
.doc-review-cursor
|
||||
|
||||
# Playwright
|
||||
tests/e2e/test-results/
|
||||
tests/e2e/playwright-report/
|
||||
tests/e2e/playwright-report/
|
||||
.superset/
|
||||
@@ -239,7 +239,7 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
|
||||
- ⚪ ClipMart - buy and sell entire agent companies
|
||||
- ⚪ Easy agent configurations / easier to understand
|
||||
- ⚪ Better support for harness engineering
|
||||
- ⚪ Plugin system (e.g. if you want to add a knowledgebase, custom tracing, queues, etc)
|
||||
- 🟢 Plugin system (e.g. if you want to add a knowledgebase, custom tracing, queues, etc)
|
||||
- ⚪ Better docs
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# paperclipai
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stable release preparation for 0.3.1
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.1
|
||||
- @paperclipai/adapter-claude-local@0.3.1
|
||||
- @paperclipai/adapter-codex-local@0.3.1
|
||||
- @paperclipai/adapter-cursor-local@0.3.1
|
||||
- @paperclipai/adapter-gemini-local@0.3.1
|
||||
- @paperclipai/adapter-openclaw-gateway@0.3.1
|
||||
- @paperclipai/adapter-opencode-local@0.3.1
|
||||
- @paperclipai/adapter-pi-local@0.3.1
|
||||
- @paperclipai/db@0.3.1
|
||||
- @paperclipai/shared@0.3.1
|
||||
- @paperclipai/server@0.3.1
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperclipai",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "Paperclip CLI — orchestrate AI agent teams to run a business",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -4,7 +4,9 @@ import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
ensureAgentJwtSecret,
|
||||
mergePaperclipEnvEntries,
|
||||
readAgentJwtSecretFromEnv,
|
||||
readPaperclipEnvEntries,
|
||||
resolveAgentJwtEnvFile,
|
||||
} from "../config/env.js";
|
||||
import { agentJwtSecretCheck } from "../checks/agent-jwt-secret-check.js";
|
||||
@@ -58,4 +60,20 @@ describe("agent jwt env helpers", () => {
|
||||
const result = agentJwtSecretCheck(configPath);
|
||||
expect(result.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("quotes hash-prefixed env values so dotenv round-trips them", () => {
|
||||
const configPath = tempConfigPath();
|
||||
const envPath = resolveAgentJwtEnvFile(configPath);
|
||||
|
||||
mergePaperclipEnvEntries(
|
||||
{
|
||||
PAPERCLIP_WORKTREE_COLOR: "#439edb",
|
||||
},
|
||||
envPath,
|
||||
);
|
||||
|
||||
const contents = fs.readFileSync(envPath, "utf-8");
|
||||
expect(contents).toContain('PAPERCLIP_WORKTREE_COLOR="#439edb"');
|
||||
expect(readPaperclipEnvEntries(envPath).PAPERCLIP_WORKTREE_COLOR).toBe("#439edb");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
copyGitHooksToWorktreeGitDir,
|
||||
copySeededSecretsKey,
|
||||
rebindWorkspaceCwd,
|
||||
resolveSourceConfigPath,
|
||||
resolveGitWorktreeAddArgs,
|
||||
resolveWorktreeMakeTargetPath,
|
||||
worktreeInitCommand,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
buildWorktreeConfig,
|
||||
buildWorktreeEnvEntries,
|
||||
formatShellExports,
|
||||
generateWorktreeColor,
|
||||
resolveWorktreeSeedPlan,
|
||||
resolveWorktreeLocalPaths,
|
||||
rewriteLocalUrlPort,
|
||||
@@ -181,13 +183,22 @@ describe("worktree helpers", () => {
|
||||
path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "data", "storage"),
|
||||
);
|
||||
|
||||
const env = buildWorktreeEnvEntries(paths);
|
||||
const env = buildWorktreeEnvEntries(paths, {
|
||||
name: "feature-worktree-support",
|
||||
color: "#3abf7a",
|
||||
});
|
||||
expect(env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-worktrees"));
|
||||
expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support");
|
||||
expect(env.PAPERCLIP_IN_WORKTREE).toBe("true");
|
||||
expect(env.PAPERCLIP_WORKTREE_NAME).toBe("feature-worktree-support");
|
||||
expect(env.PAPERCLIP_WORKTREE_COLOR).toBe("#3abf7a");
|
||||
expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'");
|
||||
});
|
||||
|
||||
it("generates vivid worktree colors as hex", () => {
|
||||
expect(generateWorktreeColor()).toMatch(/^#[0-9a-f]{6}$/);
|
||||
});
|
||||
|
||||
it("uses minimal seed mode to keep app state but drop heavy runtime history", () => {
|
||||
const minimal = resolveWorktreeSeedPlan("minimal");
|
||||
const full = resolveWorktreeSeedPlan("full");
|
||||
@@ -280,7 +291,10 @@ describe("worktree helpers", () => {
|
||||
});
|
||||
|
||||
const envPath = path.join(repoRoot, ".paperclip", ".env");
|
||||
expect(fs.readFileSync(envPath, "utf8")).toContain("PAPERCLIP_AGENT_JWT_SECRET=worktree-shared-secret");
|
||||
const envContents = fs.readFileSync(envPath, "utf8");
|
||||
expect(envContents).toContain("PAPERCLIP_AGENT_JWT_SECRET=worktree-shared-secret");
|
||||
expect(envContents).toContain("PAPERCLIP_WORKTREE_NAME=repo");
|
||||
expect(envContents).toMatch(/PAPERCLIP_WORKTREE_COLOR=\"#[0-9a-f]{6}\"/);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalJwtSecret === undefined) {
|
||||
@@ -292,6 +306,59 @@ describe("worktree helpers", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults the seed source config to the current repo-local Paperclip config", () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-config-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const localConfigPath = path.join(repoRoot, ".paperclip", "config.json");
|
||||
const originalCwd = process.cwd();
|
||||
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(localConfigPath), { recursive: true });
|
||||
fs.writeFileSync(localConfigPath, JSON.stringify(buildSourceConfig()), "utf8");
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
process.chdir(repoRoot);
|
||||
|
||||
expect(fs.realpathSync(resolveSourceConfigPath({}))).toBe(fs.realpathSync(localConfigPath));
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalPaperclipConfig === undefined) {
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
} else {
|
||||
process.env.PAPERCLIP_CONFIG = originalPaperclipConfig;
|
||||
}
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the source config path across worktree:make cwd changes", () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-override-"));
|
||||
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
|
||||
const targetRoot = path.join(tempRoot, "target");
|
||||
const originalCwd = process.cwd();
|
||||
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true });
|
||||
fs.mkdirSync(targetRoot, { recursive: true });
|
||||
fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig()), "utf8");
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
process.chdir(targetRoot);
|
||||
|
||||
expect(resolveSourceConfigPath({ sourceConfigPathOverride: sourceConfigPath })).toBe(
|
||||
path.resolve(sourceConfigPath),
|
||||
);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalPaperclipConfig === undefined) {
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
} else {
|
||||
process.env.PAPERCLIP_CONFIG = originalPaperclipConfig;
|
||||
}
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rebinds same-repo workspace paths onto the current worktree root", () => {
|
||||
expect(
|
||||
rebindWorkspaceCwd({
|
||||
|
||||
374
cli/src/commands/client/plugin.ts
Normal file
374
cli/src/commands/client/plugin.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import pc from "picocolors";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
handleCommandError,
|
||||
printOutput,
|
||||
resolveCommandContext,
|
||||
type BaseClientOptions,
|
||||
} from "./common.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types mirroring server-side shapes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PluginRecord {
|
||||
id: string;
|
||||
pluginKey: string;
|
||||
packageName: string;
|
||||
version: string;
|
||||
status: string;
|
||||
displayName?: string;
|
||||
lastError?: string | null;
|
||||
installedAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Option types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PluginListOptions extends BaseClientOptions {
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface PluginInstallOptions extends BaseClientOptions {
|
||||
local?: boolean;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
interface PluginUninstallOptions extends BaseClientOptions {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve a local path argument to an absolute path so the server can find the
|
||||
* plugin on disk regardless of where the user ran the CLI.
|
||||
*/
|
||||
function resolvePackageArg(packageArg: string, isLocal: boolean): string {
|
||||
if (!isLocal) return packageArg;
|
||||
// Already absolute
|
||||
if (path.isAbsolute(packageArg)) return packageArg;
|
||||
// Expand leading ~ to home directory
|
||||
if (packageArg.startsWith("~")) {
|
||||
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
||||
return path.resolve(home, packageArg.slice(1).replace(/^[\\/]/, ""));
|
||||
}
|
||||
return path.resolve(process.cwd(), packageArg);
|
||||
}
|
||||
|
||||
function formatPlugin(p: PluginRecord): string {
|
||||
const statusColor =
|
||||
p.status === "ready"
|
||||
? pc.green(p.status)
|
||||
: p.status === "error"
|
||||
? pc.red(p.status)
|
||||
: p.status === "disabled"
|
||||
? pc.dim(p.status)
|
||||
: pc.yellow(p.status);
|
||||
|
||||
const parts = [
|
||||
`key=${pc.bold(p.pluginKey)}`,
|
||||
`status=${statusColor}`,
|
||||
`version=${p.version}`,
|
||||
`id=${pc.dim(p.id)}`,
|
||||
];
|
||||
|
||||
if (p.lastError) {
|
||||
parts.push(`error=${pc.red(p.lastError.slice(0, 80))}`);
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function registerPluginCommands(program: Command): void {
|
||||
const plugin = program.command("plugin").description("Plugin lifecycle management");
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin list
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("list")
|
||||
.description("List installed plugins")
|
||||
.option("--status <status>", "Filter by status (ready, error, disabled, installed, upgrade_pending)")
|
||||
.action(async (opts: PluginListOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const qs = opts.status ? `?status=${encodeURIComponent(opts.status)}` : "";
|
||||
const plugins = await ctx.api.get<PluginRecord[]>(`/api/plugins${qs}`);
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(plugins, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = plugins ?? [];
|
||||
if (rows.length === 0) {
|
||||
console.log(pc.dim("No plugins installed."));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const p of rows) {
|
||||
console.log(formatPlugin(p));
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin install <package-or-path>
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("install <package>")
|
||||
.description(
|
||||
"Install a plugin from a local path or npm package.\n" +
|
||||
" Examples:\n" +
|
||||
" paperclipai plugin install ./my-plugin # local path\n" +
|
||||
" paperclipai plugin install @acme/plugin-linear # npm package\n" +
|
||||
" paperclipai plugin install @acme/plugin-linear@1.2 # pinned version",
|
||||
)
|
||||
.option("-l, --local", "Treat <package> as a local filesystem path", false)
|
||||
.option("--version <version>", "Specific npm version to install (npm packages only)")
|
||||
.action(async (packageArg: string, opts: PluginInstallOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
|
||||
// Auto-detect local paths: starts with . or / or ~ or is an absolute path
|
||||
const isLocal =
|
||||
opts.local ||
|
||||
packageArg.startsWith("./") ||
|
||||
packageArg.startsWith("../") ||
|
||||
packageArg.startsWith("/") ||
|
||||
packageArg.startsWith("~");
|
||||
|
||||
const resolvedPackage = resolvePackageArg(packageArg, isLocal);
|
||||
|
||||
if (!ctx.json) {
|
||||
console.log(
|
||||
pc.dim(
|
||||
isLocal
|
||||
? `Installing plugin from local path: ${resolvedPackage}`
|
||||
: `Installing plugin: ${resolvedPackage}${opts.version ? `@${opts.version}` : ""}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const installedPlugin = await ctx.api.post<PluginRecord>("/api/plugins/install", {
|
||||
packageName: resolvedPackage,
|
||||
version: opts.version,
|
||||
isLocalPath: isLocal,
|
||||
});
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(installedPlugin, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!installedPlugin) {
|
||||
console.log(pc.dim("Install returned no plugin record."));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
pc.green(
|
||||
`✓ Installed ${pc.bold(installedPlugin.pluginKey)} v${installedPlugin.version} (${installedPlugin.status})`,
|
||||
),
|
||||
);
|
||||
|
||||
if (installedPlugin.lastError) {
|
||||
console.log(pc.red(` Warning: ${installedPlugin.lastError}`));
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin uninstall <plugin-key-or-id>
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("uninstall <pluginKey>")
|
||||
.description(
|
||||
"Uninstall a plugin by its plugin key or database ID.\n" +
|
||||
" Use --force to hard-purge all state and config.",
|
||||
)
|
||||
.option("--force", "Purge all plugin state and config (hard delete)", false)
|
||||
.action(async (pluginKey: string, opts: PluginUninstallOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const purge = opts.force === true;
|
||||
const qs = purge ? "?purge=true" : "";
|
||||
|
||||
if (!ctx.json) {
|
||||
console.log(
|
||||
pc.dim(
|
||||
purge
|
||||
? `Uninstalling and purging plugin: ${pluginKey}`
|
||||
: `Uninstalling plugin: ${pluginKey}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const result = await ctx.api.delete<PluginRecord | null>(
|
||||
`/api/plugins/${encodeURIComponent(pluginKey)}${qs}`,
|
||||
);
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(result, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(pc.green(`✓ Uninstalled ${pc.bold(pluginKey)}${purge ? " (purged)" : ""}`));
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin enable <plugin-key-or-id>
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("enable <pluginKey>")
|
||||
.description("Enable a disabled or errored plugin")
|
||||
.action(async (pluginKey: string, opts: BaseClientOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const result = await ctx.api.post<PluginRecord>(
|
||||
`/api/plugins/${encodeURIComponent(pluginKey)}/enable`,
|
||||
);
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(result, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(pc.green(`✓ Enabled ${pc.bold(pluginKey)} — status: ${result?.status ?? "unknown"}`));
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin disable <plugin-key-or-id>
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("disable <pluginKey>")
|
||||
.description("Disable a running plugin without uninstalling it")
|
||||
.action(async (pluginKey: string, opts: BaseClientOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const result = await ctx.api.post<PluginRecord>(
|
||||
`/api/plugins/${encodeURIComponent(pluginKey)}/disable`,
|
||||
);
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(result, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(pc.dim(`Disabled ${pc.bold(pluginKey)} — status: ${result?.status ?? "unknown"}`));
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin inspect <plugin-key-or-id>
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("inspect <pluginKey>")
|
||||
.description("Show full details for an installed plugin")
|
||||
.action(async (pluginKey: string, opts: BaseClientOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const result = await ctx.api.get<PluginRecord>(
|
||||
`/api/plugins/${encodeURIComponent(pluginKey)}`,
|
||||
);
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(result, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
console.log(pc.red(`Plugin not found: ${pluginKey}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(formatPlugin(result));
|
||||
if (result.lastError) {
|
||||
console.log(`\n${pc.red("Last error:")}\n${result.lastError}`);
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin examples
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("examples")
|
||||
.description("List bundled example plugins available for local install")
|
||||
.action(async (opts: BaseClientOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const examples = await ctx.api.get<
|
||||
Array<{
|
||||
packageName: string;
|
||||
pluginKey: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
localPath: string;
|
||||
tag: string;
|
||||
}>
|
||||
>("/api/plugins/examples");
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(examples, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = examples ?? [];
|
||||
if (rows.length === 0) {
|
||||
console.log(pc.dim("No bundled examples available."));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const ex of rows) {
|
||||
console.log(
|
||||
`${pc.bold(ex.displayName)} ${pc.dim(ex.pluginKey)}\n` +
|
||||
` ${ex.description}\n` +
|
||||
` ${pc.cyan(`paperclipai plugin install ${ex.localPath}`)}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomInt } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import { expandHomePrefix } from "../config/home.js";
|
||||
@@ -44,6 +45,11 @@ export type WorktreeLocalPaths = {
|
||||
storageDir: string;
|
||||
};
|
||||
|
||||
export type WorktreeUiBranding = {
|
||||
name: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export function isWorktreeSeedMode(value: string): value is WorktreeSeedMode {
|
||||
return (WORKTREE_SEED_MODES as readonly string[]).includes(value);
|
||||
}
|
||||
@@ -87,6 +93,51 @@ export function resolveSuggestedWorktreeName(cwd: string, explicitName?: string)
|
||||
return nonEmpty(explicitName) ?? path.basename(path.resolve(cwd));
|
||||
}
|
||||
|
||||
function hslComponentToHex(n: number): string {
|
||||
return Math.round(Math.max(0, Math.min(255, n)))
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
}
|
||||
|
||||
function hslToHex(hue: number, saturation: number, lightness: number): string {
|
||||
const s = Math.max(0, Math.min(100, saturation)) / 100;
|
||||
const l = Math.max(0, Math.min(100, lightness)) / 100;
|
||||
const c = (1 - Math.abs((2 * l) - 1)) * s;
|
||||
const h = ((hue % 360) + 360) % 360;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = l - (c / 2);
|
||||
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
|
||||
if (h < 60) {
|
||||
r = c;
|
||||
g = x;
|
||||
} else if (h < 120) {
|
||||
r = x;
|
||||
g = c;
|
||||
} else if (h < 180) {
|
||||
g = c;
|
||||
b = x;
|
||||
} else if (h < 240) {
|
||||
g = x;
|
||||
b = c;
|
||||
} else if (h < 300) {
|
||||
r = x;
|
||||
b = c;
|
||||
} else {
|
||||
r = c;
|
||||
b = x;
|
||||
}
|
||||
|
||||
return `#${hslComponentToHex((r + m) * 255)}${hslComponentToHex((g + m) * 255)}${hslComponentToHex((b + m) * 255)}`;
|
||||
}
|
||||
|
||||
export function generateWorktreeColor(): string {
|
||||
return hslToHex(randomInt(0, 360), 68, 56);
|
||||
}
|
||||
|
||||
export function resolveWorktreeLocalPaths(opts: {
|
||||
cwd: string;
|
||||
homeDir?: string;
|
||||
@@ -196,13 +247,18 @@ export function buildWorktreeConfig(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWorktreeEnvEntries(paths: WorktreeLocalPaths): Record<string, string> {
|
||||
export function buildWorktreeEnvEntries(
|
||||
paths: WorktreeLocalPaths,
|
||||
branding?: WorktreeUiBranding,
|
||||
): Record<string, string> {
|
||||
return {
|
||||
PAPERCLIP_HOME: paths.homeDir,
|
||||
PAPERCLIP_INSTANCE_ID: paths.instanceId,
|
||||
PAPERCLIP_CONFIG: paths.configPath,
|
||||
PAPERCLIP_CONTEXT: paths.contextPath,
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
...(branding?.name ? { PAPERCLIP_WORKTREE_NAME: branding.name } : {}),
|
||||
...(branding?.color ? { PAPERCLIP_WORKTREE_COLOR: branding.color } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
buildWorktreeEnvEntries,
|
||||
DEFAULT_WORKTREE_HOME,
|
||||
formatShellExports,
|
||||
generateWorktreeColor,
|
||||
isWorktreeSeedMode,
|
||||
resolveSuggestedWorktreeName,
|
||||
resolveWorktreeSeedPlan,
|
||||
@@ -55,6 +56,7 @@ type WorktreeInitOptions = {
|
||||
fromConfig?: string;
|
||||
fromDataDir?: string;
|
||||
fromInstance?: string;
|
||||
sourceConfigPathOverride?: string;
|
||||
serverPort?: number;
|
||||
dbPort?: number;
|
||||
seed?: boolean;
|
||||
@@ -425,8 +427,12 @@ async function rebindSeededProjectWorkspaces(input: {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSourceConfigPath(opts: WorktreeInitOptions): string {
|
||||
export function resolveSourceConfigPath(opts: WorktreeInitOptions): string {
|
||||
if (opts.sourceConfigPathOverride) return path.resolve(opts.sourceConfigPathOverride);
|
||||
if (opts.fromConfig) return path.resolve(opts.fromConfig);
|
||||
if (!opts.fromDataDir && !opts.fromInstance) {
|
||||
return resolveConfigPath();
|
||||
}
|
||||
const sourceHome = path.resolve(expandHomePrefix(opts.fromDataDir ?? "~/.paperclip"));
|
||||
const sourceInstanceId = sanitizeWorktreeInstanceId(opts.fromInstance ?? "default");
|
||||
return path.resolve(sourceHome, "instances", sourceInstanceId, "config.json");
|
||||
@@ -623,7 +629,7 @@ async function seedWorktreeDatabase(input: {
|
||||
|
||||
async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
||||
const cwd = process.cwd();
|
||||
const name = resolveSuggestedWorktreeName(
|
||||
const worktreeName = resolveSuggestedWorktreeName(
|
||||
cwd,
|
||||
opts.name ?? detectGitBranchName(cwd) ?? undefined,
|
||||
);
|
||||
@@ -631,12 +637,16 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
||||
if (!isWorktreeSeedMode(seedMode)) {
|
||||
throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`);
|
||||
}
|
||||
const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? name);
|
||||
const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? worktreeName);
|
||||
const paths = resolveWorktreeLocalPaths({
|
||||
cwd,
|
||||
homeDir: resolveWorktreeHome(opts.home),
|
||||
instanceId,
|
||||
});
|
||||
const branding = {
|
||||
name: worktreeName,
|
||||
color: generateWorktreeColor(),
|
||||
};
|
||||
const sourceConfigPath = resolveSourceConfigPath(opts);
|
||||
const sourceConfig = existsSync(sourceConfigPath) ? readConfig(sourceConfigPath) : null;
|
||||
|
||||
@@ -669,7 +679,7 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
||||
nonEmpty(process.env.PAPERCLIP_AGENT_JWT_SECRET);
|
||||
mergePaperclipEnvEntries(
|
||||
{
|
||||
...buildWorktreeEnvEntries(paths),
|
||||
...buildWorktreeEnvEntries(paths, branding),
|
||||
...(existingAgentJwtSecret ? { PAPERCLIP_AGENT_JWT_SECRET: existingAgentJwtSecret } : {}),
|
||||
},
|
||||
paths.envPath,
|
||||
@@ -710,6 +720,7 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
||||
p.log.message(pc.dim(`Repo env: ${paths.envPath}`));
|
||||
p.log.message(pc.dim(`Isolated home: ${paths.homeDir}`));
|
||||
p.log.message(pc.dim(`Instance: ${paths.instanceId}`));
|
||||
p.log.message(pc.dim(`Worktree badge: ${branding.name} (${branding.color})`));
|
||||
p.log.message(pc.dim(`Server port: ${serverPort} | DB port: ${databasePort}`));
|
||||
if (copiedGitHooks?.copied) {
|
||||
p.log.message(
|
||||
@@ -745,6 +756,7 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt
|
||||
const name = resolveWorktreeMakeName(nameArg);
|
||||
const startPoint = resolveWorktreeStartPoint(opts.startPoint);
|
||||
const sourceCwd = process.cwd();
|
||||
const sourceConfigPath = resolveSourceConfigPath(opts);
|
||||
const targetPath = resolveWorktreeMakeTargetPath(name);
|
||||
if (existsSync(targetPath)) {
|
||||
throw new Error(`Target path already exists: ${targetPath}`);
|
||||
@@ -804,6 +816,7 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt
|
||||
await runWorktreeInit({
|
||||
...opts,
|
||||
name,
|
||||
sourceConfigPathOverride: sourceConfigPath,
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
|
||||
@@ -22,11 +22,18 @@ function parseEnvFile(contents: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatEnvValue(value: string): string {
|
||||
if (/^[A-Za-z0-9_./:@-]+$/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function renderEnvFile(entries: Record<string, string>) {
|
||||
const lines = [
|
||||
"# Paperclip environment variables",
|
||||
"# Generated by Paperclip CLI commands",
|
||||
...Object.entries(entries).map(([key, value]) => `${key}=${value}`),
|
||||
...Object.entries(entries).map(([key, value]) => `${key}=${formatEnvValue(value)}`),
|
||||
"",
|
||||
];
|
||||
return lines.join("\n");
|
||||
|
||||
@@ -18,6 +18,7 @@ import { registerDashboardCommands } from "./commands/client/dashboard.js";
|
||||
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
|
||||
import { loadPaperclipEnvFile } from "./config/env.js";
|
||||
import { registerWorktreeCommands } from "./commands/worktree.js";
|
||||
import { registerPluginCommands } from "./commands/client/plugin.js";
|
||||
|
||||
const program = new Command();
|
||||
const DATA_DIR_OPTION_HELP =
|
||||
@@ -136,6 +137,7 @@ registerApprovalCommands(program);
|
||||
registerActivityCommands(program);
|
||||
registerDashboardCommands(program);
|
||||
registerWorktreeCommands(program);
|
||||
registerPluginCommands(program);
|
||||
|
||||
const auth = program.command("auth").description("Authentication and bootstrap utilities");
|
||||
|
||||
|
||||
@@ -89,6 +89,10 @@ docker compose -f docker-compose.quickstart.yml up --build
|
||||
|
||||
See `doc/DOCKER.md` for API key wiring (`OPENAI_API_KEY` / `ANTHROPIC_API_KEY`) and persistence details.
|
||||
|
||||
## Docker For Untrusted PR Review
|
||||
|
||||
For a separate review-oriented container that keeps `codex`/`claude` login state in Docker volumes and checks out PRs into an isolated scratch workspace, see `doc/UNTRUSTED-PR-REVIEW.md`.
|
||||
|
||||
## Database in Dev (Auto-Handled)
|
||||
|
||||
For local development, leave `DATABASE_URL` unset.
|
||||
@@ -142,7 +146,7 @@ This command:
|
||||
- creates an isolated instance under `~/.paperclip-worktrees/instances/<worktree-id>/`
|
||||
- when run inside a linked git worktree, mirrors the effective git hooks into that worktree's private git dir
|
||||
- picks a free app port and embedded PostgreSQL port
|
||||
- by default seeds the isolated DB in `minimal` mode from your main instance via a logical SQL snapshot
|
||||
- by default seeds the isolated DB in `minimal` mode from the current effective Paperclip instance/config (repo-local worktree config when present, otherwise the default instance) via a logical SQL snapshot
|
||||
|
||||
Seed modes:
|
||||
|
||||
@@ -152,7 +156,13 @@ Seed modes:
|
||||
|
||||
After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance.
|
||||
|
||||
That repo-local env also sets `PAPERCLIP_IN_WORKTREE=true`, which the server can use for worktree-specific UI behavior such as an alternate favicon.
|
||||
That repo-local env also sets:
|
||||
|
||||
- `PAPERCLIP_IN_WORKTREE=true`
|
||||
- `PAPERCLIP_WORKTREE_NAME=<worktree-name>`
|
||||
- `PAPERCLIP_WORKTREE_COLOR=<hex-color>`
|
||||
|
||||
The server/UI use those values for worktree-specific branding such as the top banner and dynamically colored favicon.
|
||||
|
||||
Print shell exports explicitly when needed:
|
||||
|
||||
|
||||
@@ -93,6 +93,12 @@ Notes:
|
||||
- Without API keys, the app still runs normally.
|
||||
- Adapter environment checks in Paperclip will surface missing auth/CLI prerequisites.
|
||||
|
||||
## Untrusted PR Review Container
|
||||
|
||||
If you want a separate Docker environment for reviewing untrusted pull requests with `codex` or `claude`, use the dedicated review workflow in `doc/UNTRUSTED-PR-REVIEW.md`.
|
||||
|
||||
That setup keeps CLI auth state in Docker volumes instead of your host home directory and uses a separate scratch workspace for PR checkouts and preview runs.
|
||||
|
||||
## Onboard Smoke Test (Ubuntu + npm only)
|
||||
|
||||
Use this when you want to mimic a fresh machine that only has Ubuntu + npm and verify:
|
||||
|
||||
@@ -330,6 +330,34 @@ Operational policy:
|
||||
- `asset_id` uuid fk not null
|
||||
- `issue_comment_id` uuid fk null
|
||||
|
||||
## 7.15 `documents` + `document_revisions` + `issue_documents`
|
||||
|
||||
- `documents` stores editable text-first documents:
|
||||
- `id` uuid pk
|
||||
- `company_id` uuid fk not null
|
||||
- `title` text null
|
||||
- `format` text not null (`markdown`)
|
||||
- `latest_body` text not null
|
||||
- `latest_revision_id` uuid null
|
||||
- `latest_revision_number` int not null
|
||||
- `created_by_agent_id` uuid fk null
|
||||
- `created_by_user_id` uuid/text fk null
|
||||
- `updated_by_agent_id` uuid fk null
|
||||
- `updated_by_user_id` uuid/text fk null
|
||||
- `document_revisions` stores append-only history:
|
||||
- `id` uuid pk
|
||||
- `company_id` uuid fk not null
|
||||
- `document_id` uuid fk not null
|
||||
- `revision_number` int not null
|
||||
- `body` text not null
|
||||
- `change_summary` text null
|
||||
- `issue_documents` links documents to issues with a stable workflow key:
|
||||
- `id` uuid pk
|
||||
- `company_id` uuid fk not null
|
||||
- `issue_id` uuid fk not null
|
||||
- `document_id` uuid fk not null
|
||||
- `key` text not null (`plan`, `design`, `notes`, etc.)
|
||||
|
||||
## 8. State Machines
|
||||
|
||||
## 8.1 Agent Status
|
||||
@@ -441,6 +469,11 @@ All endpoints are under `/api` and return JSON.
|
||||
- `POST /companies/:companyId/issues`
|
||||
- `GET /issues/:issueId`
|
||||
- `PATCH /issues/:issueId`
|
||||
- `GET /issues/:issueId/documents`
|
||||
- `GET /issues/:issueId/documents/:key`
|
||||
- `PUT /issues/:issueId/documents/:key`
|
||||
- `GET /issues/:issueId/documents/:key/revisions`
|
||||
- `DELETE /issues/:issueId/documents/:key`
|
||||
- `POST /issues/:issueId/checkout`
|
||||
- `POST /issues/:issueId/release`
|
||||
- `POST /issues/:issueId/comments`
|
||||
|
||||
15
doc/SPEC.md
15
doc/SPEC.md
@@ -188,12 +188,15 @@ The heartbeat is a protocol, not a runtime. Paperclip defines how to initiate an
|
||||
|
||||
Agent configuration includes an **adapter** that defines how Paperclip invokes the agent. Initial adapters:
|
||||
|
||||
| Adapter | Mechanism | Example |
|
||||
| --------- | ----------------------- | --------------------------------------------- |
|
||||
| `process` | Execute a child process | `python run_agent.py --agent-id {id}` |
|
||||
| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` |
|
||||
| Adapter | Mechanism | Example |
|
||||
| -------------------- | ----------------------- | --------------------------------------------- |
|
||||
| `process` | Execute a child process | `python run_agent.py --agent-id {id}` |
|
||||
| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` |
|
||||
| `openclaw_gateway` | OpenClaw gateway API | Managed OpenClaw agent via gateway |
|
||||
| `gemini_local` | Gemini CLI process | Local Gemini CLI with sandbox and approval |
|
||||
| `hermes_local` | Hermes agent process | Local Hermes agent |
|
||||
|
||||
The `process` and `http` adapters ship as defaults. Additional adapters can be added via the plugin system (see Plugin / Extension Architecture).
|
||||
The `process` and `http` adapters ship as defaults. Additional adapters have been added for specific agent runtimes (see list above), and new adapter types can be registered via the plugin system (see Plugin / Extension Architecture).
|
||||
|
||||
### Adapter Interface
|
||||
|
||||
@@ -429,7 +432,7 @@ The core Paperclip system must be extensible. Features like knowledge bases, ext
|
||||
- **Agent Adapter plugins** — new Adapter types can be registered via the plugin system
|
||||
- Plugin-registrable UI components (future)
|
||||
|
||||
This isn't a V1 deliverable (we're not building a plugin framework upfront), but the architecture should not paint us into a corner. Keep boundaries clean so extensions are possible.
|
||||
The plugin framework has shipped. Plugins can register new adapter types, hook into lifecycle events, and contribute UI components (e.g. global toolbar buttons). A plugin SDK and CLI commands (`paperclipai plugin`) are available for authoring and installing plugins.
|
||||
|
||||
---
|
||||
|
||||
|
||||
135
doc/UNTRUSTED-PR-REVIEW.md
Normal file
135
doc/UNTRUSTED-PR-REVIEW.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Untrusted PR Review In Docker
|
||||
|
||||
Use this workflow when you want Codex or Claude to inspect a pull request that you do not want touching your host machine directly.
|
||||
|
||||
This is intentionally separate from the normal Paperclip dev image.
|
||||
|
||||
## What this container isolates
|
||||
|
||||
- `codex` auth/session state in a Docker volume, not your host `~/.codex`
|
||||
- `claude` auth/session state in a Docker volume, not your host `~/.claude`
|
||||
- `gh` auth state in the same container-local home volume
|
||||
- review clones, worktrees, dependency installs, and local databases in a writable scratch volume under `/work`
|
||||
|
||||
By default this workflow does **not** mount your host repo checkout, your host home directory, or your SSH agent.
|
||||
|
||||
## Files
|
||||
|
||||
- `docker/untrusted-review/Dockerfile`
|
||||
- `docker-compose.untrusted-review.yml`
|
||||
- `review-checkout-pr` inside the container
|
||||
|
||||
## Build and start a shell
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.untrusted-review.yml build
|
||||
docker compose -f docker-compose.untrusted-review.yml run --rm --service-ports review
|
||||
```
|
||||
|
||||
That opens an interactive shell in the review container with:
|
||||
|
||||
- Node + Corepack/pnpm
|
||||
- `codex`
|
||||
- `claude`
|
||||
- `gh`
|
||||
- `git`, `rg`, `fd`, `jq`
|
||||
|
||||
## First-time login inside the container
|
||||
|
||||
Run these once. The resulting login state persists in the `review-home` Docker volume.
|
||||
|
||||
```sh
|
||||
gh auth login
|
||||
codex login
|
||||
claude login
|
||||
```
|
||||
|
||||
If you prefer API-key auth instead of CLI login, pass keys through Compose env:
|
||||
|
||||
```sh
|
||||
OPENAI_API_KEY=... ANTHROPIC_API_KEY=... docker compose -f docker-compose.untrusted-review.yml run --rm review
|
||||
```
|
||||
|
||||
## Check out a PR safely
|
||||
|
||||
Inside the container:
|
||||
|
||||
```sh
|
||||
review-checkout-pr paperclipai/paperclip 432
|
||||
cd /work/checkouts/paperclipai-paperclip/pr-432
|
||||
```
|
||||
|
||||
What this does:
|
||||
|
||||
1. Creates or reuses a repo clone under `/work/repos/...`
|
||||
2. Fetches `pull/<pr>/head` from GitHub
|
||||
3. Creates a detached git worktree under `/work/checkouts/...`
|
||||
|
||||
The checkout lives entirely inside the container volume.
|
||||
|
||||
## Ask Codex or Claude to review it
|
||||
|
||||
Inside the PR checkout:
|
||||
|
||||
```sh
|
||||
codex
|
||||
```
|
||||
|
||||
Then give it a prompt like:
|
||||
|
||||
```text
|
||||
Review this PR as hostile input. Focus on security issues, data exfiltration paths, sandbox escapes, dangerous install/runtime scripts, auth changes, and subtle behavioral regressions. Do not modify files. Produce findings ordered by severity with file references.
|
||||
```
|
||||
|
||||
Or with Claude:
|
||||
|
||||
```sh
|
||||
claude
|
||||
```
|
||||
|
||||
## Preview the Paperclip app from the PR
|
||||
|
||||
Only do this when you intentionally want to execute the PR's code inside the container.
|
||||
|
||||
Inside the PR checkout:
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
HOST=0.0.0.0 pnpm dev
|
||||
```
|
||||
|
||||
Open from the host:
|
||||
|
||||
- `http://localhost:3100`
|
||||
|
||||
The Compose file also exposes Vite's default port:
|
||||
|
||||
- `http://localhost:5173`
|
||||
|
||||
Notes:
|
||||
|
||||
- `pnpm install` can run untrusted lifecycle scripts from the PR. That is why this happens inside the isolated container instead of on your host.
|
||||
- If you only want static inspection, do not run install/dev commands.
|
||||
- Paperclip's embedded PostgreSQL and local storage stay inside the container home volume via `PAPERCLIP_HOME=/home/reviewer/.paperclip-review`.
|
||||
|
||||
## Reset state
|
||||
|
||||
Remove the review container volumes when you want a clean environment:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.untrusted-review.yml down -v
|
||||
```
|
||||
|
||||
That deletes:
|
||||
|
||||
- Codex/Claude/GitHub login state stored in `review-home`
|
||||
- cloned repos, worktrees, installs, and scratch data stored in `review-work`
|
||||
|
||||
## Security limits
|
||||
|
||||
This is a useful isolation boundary, but it is still Docker, not a full VM.
|
||||
|
||||
- A reviewed PR can still access the container's network unless you disable it.
|
||||
- Any secrets you pass into the container are available to code you execute inside it.
|
||||
- Do not mount your host repo, host home, `.ssh`, or Docker socket unless you are intentionally weakening the boundary.
|
||||
- If you need a stronger boundary than this, use a disposable VM instead of Docker.
|
||||
@@ -118,10 +118,18 @@ Result:
|
||||
|
||||
Local adapters inject repo skills into runtime skill directories.
|
||||
|
||||
Important `codex_local` nuance:
|
||||
|
||||
- Codex does not read skills directly from the active worktree.
|
||||
- Paperclip discovers repo skills from the current checkout, then symlinks them into `$CODEX_HOME/skills` or `~/.codex/skills`.
|
||||
- If an existing Paperclip skill symlink already points at another live checkout, the current implementation skips it instead of repointing it.
|
||||
- This can leave Codex using stale skill content from a different worktree even after Paperclip-side skill changes land.
|
||||
- That is both a correctness risk and a token-analysis risk, because runtime behavior may not reflect the instructions in the checkout being tested.
|
||||
|
||||
Current repo skill sizes:
|
||||
|
||||
- `skills/paperclip/SKILL.md`: 17,441 bytes
|
||||
- `skills/create-agent-adapter/SKILL.md`: 31,832 bytes
|
||||
- `.agents/skills/create-agent-adapter/SKILL.md`: 31,832 bytes
|
||||
- `skills/paperclip-create-agent/SKILL.md`: 4,718 bytes
|
||||
- `skills/para-memory-files/SKILL.md`: 3,978 bytes
|
||||
|
||||
@@ -215,6 +223,8 @@ This is the right version of the discussion’s bootstrap idea.
|
||||
|
||||
Static instructions and dynamic wake context have different cache behavior and should be modeled separately.
|
||||
|
||||
For `codex_local`, this also requires isolating the Codex skill home per worktree or teaching Paperclip to repoint its own skill symlinks when the source checkout changes. Otherwise prompt and skill improvements in the active worktree may not reach the running agent.
|
||||
|
||||
### Success criteria
|
||||
|
||||
- fresh-session prompts can remain richer without inflating every resumed heartbeat
|
||||
@@ -305,6 +315,9 @@ Even when reuse is desirable, some sessions become too expensive to keep alive i
|
||||
- `para-memory-files`
|
||||
- `create-agent-adapter`
|
||||
- Expose active skill set in agent config and run metadata.
|
||||
- For `codex_local`, either:
|
||||
- run with a worktree-specific `CODEX_HOME`, or
|
||||
- treat Paperclip-owned Codex skill symlinks as repairable when they point at a different checkout
|
||||
|
||||
### Why
|
||||
|
||||
@@ -363,6 +376,7 @@ Initial targets:
|
||||
6. Rewrite `skills/paperclip/SKILL.md` around delta-fetch behavior.
|
||||
7. Add session rotation with carry-forward summaries.
|
||||
8. Replace global skill injection with explicit allowlists.
|
||||
9. Fix `codex_local` skill resolution so worktree-local skill changes reliably reach the runtime.
|
||||
|
||||
## Recommendation
|
||||
|
||||
|
||||
775
doc/plans/2026-03-13-agent-evals-framework.md
Normal file
775
doc/plans/2026-03-13-agent-evals-framework.md
Normal file
@@ -0,0 +1,775 @@
|
||||
# Agent Evals Framework Plan
|
||||
|
||||
Date: 2026-03-13
|
||||
|
||||
## Context
|
||||
|
||||
We need evals for the thing Paperclip actually ships:
|
||||
|
||||
- agent behavior produced by adapter config
|
||||
- prompt templates and bootstrap prompts
|
||||
- skill sets and skill instructions
|
||||
- model choice
|
||||
- runtime policy choices that affect outcomes and cost
|
||||
|
||||
We do **not** primarily need a fine-tuning pipeline.
|
||||
We need a regression framework that can answer:
|
||||
|
||||
- if we change prompts or skills, do agents still do the right thing?
|
||||
- if we switch models, what got better, worse, or more expensive?
|
||||
- if we optimize tokens, did we preserve task outcomes?
|
||||
- can we grow the suite over time from real Paperclip usage?
|
||||
|
||||
This plan is based on:
|
||||
|
||||
- `doc/GOAL.md`
|
||||
- `doc/PRODUCT.md`
|
||||
- `doc/SPEC-implementation.md`
|
||||
- `docs/agents-runtime.md`
|
||||
- `doc/plans/2026-03-13-TOKEN-OPTIMIZATION-PLAN.md`
|
||||
- Discussion #449: <https://github.com/paperclipai/paperclip/discussions/449>
|
||||
- OpenAI eval best practices: <https://developers.openai.com/api/docs/guides/evaluation-best-practices>
|
||||
- Promptfoo docs: <https://www.promptfoo.dev/docs/configuration/test-cases/> and <https://www.promptfoo.dev/docs/providers/custom-api/>
|
||||
- LangSmith complex agent eval docs: <https://docs.langchain.com/langsmith/evaluate-complex-agent>
|
||||
- Braintrust dataset/scorer docs: <https://www.braintrust.dev/docs/annotate/datasets> and <https://www.braintrust.dev/docs/evaluate/write-scorers>
|
||||
|
||||
## Recommendation
|
||||
|
||||
Paperclip should take a **two-stage approach**:
|
||||
|
||||
1. **Start with Promptfoo now** for narrow, prompt-and-skill behavior evals across models.
|
||||
2. **Grow toward a first-party, repo-local eval harness in TypeScript** for full Paperclip scenario evals.
|
||||
|
||||
So the recommendation is no longer “skip Promptfoo.” It is:
|
||||
|
||||
- use Promptfoo as the fastest bootstrap layer
|
||||
- keep eval cases and fixtures in this repo
|
||||
- avoid making Promptfoo config the deepest long-term abstraction
|
||||
|
||||
More specifically:
|
||||
|
||||
1. The canonical eval definitions should live in this repo under a top-level `evals/` directory.
|
||||
2. `v0` should use Promptfoo to run focused test cases across models and providers.
|
||||
3. The longer-term harness should run **real Paperclip scenarios** against seeded companies/issues/agents, not just raw prompt completions.
|
||||
4. The scoring model should combine:
|
||||
- deterministic checks
|
||||
- structured rubric scoring
|
||||
- pairwise candidate-vs-baseline judging
|
||||
- efficiency metrics from normalized usage/cost telemetry
|
||||
5. The framework should compare **bundles**, not just models.
|
||||
|
||||
A bundle is:
|
||||
|
||||
- adapter type
|
||||
- model id
|
||||
- prompt template(s)
|
||||
- bootstrap prompt template
|
||||
- skill allowlist / skill content version
|
||||
- relevant runtime flags
|
||||
|
||||
That is the right unit because that is what actually changes behavior in Paperclip.
|
||||
|
||||
## Why This Is The Right Shape
|
||||
|
||||
### 1. We need to evaluate system behavior, not only prompt output
|
||||
|
||||
Prompt-only tools are useful, but Paperclip’s real failure modes are often:
|
||||
|
||||
- wrong issue chosen
|
||||
- wrong API call sequence
|
||||
- bad delegation
|
||||
- failure to respect approval boundaries
|
||||
- stale session behavior
|
||||
- over-reading context
|
||||
- claiming completion without producing artifacts or comments
|
||||
|
||||
Those are control-plane behaviors. They require scenario setup, execution, and trace inspection.
|
||||
|
||||
### 2. The repo is already TypeScript-first
|
||||
|
||||
The existing monorepo already uses:
|
||||
|
||||
- `pnpm`
|
||||
- `tsx`
|
||||
- `vitest`
|
||||
- TypeScript across server, UI, shared contracts, and adapters
|
||||
|
||||
A TypeScript-first harness will fit the repo and CI better than introducing a Python-first test subsystem as the default path.
|
||||
|
||||
Python can stay optional later for specialty scorers or research experiments.
|
||||
|
||||
### 3. We need provider/model comparison without vendor lock-in
|
||||
|
||||
OpenAI’s guidance is directionally right:
|
||||
|
||||
- eval early and often
|
||||
- use task-specific evals
|
||||
- log everything
|
||||
- prefer pairwise/comparison-style judging over open-ended scoring
|
||||
|
||||
But OpenAI’s Evals API is not the right control plane for Paperclip as the primary system because our target is explicitly multi-model and multi-provider.
|
||||
|
||||
### 4. Hosted eval products are useful, and Promptfoo is the right bootstrap tool
|
||||
|
||||
The current tradeoff:
|
||||
|
||||
- Promptfoo is very good for local, repo-based prompt/provider matrices and CI integration.
|
||||
- LangSmith is strong on trajectory-style agent evals.
|
||||
- Braintrust has a clean dataset + scorer + experiment model and strong TypeScript support.
|
||||
|
||||
The community suggestion is directionally right:
|
||||
|
||||
- Promptfoo lets us start small
|
||||
- it supports simple assertions like contains / not-contains / regex / custom JS
|
||||
- it can run the same cases across multiple models
|
||||
- it supports OpenRouter
|
||||
- it can move into CI later
|
||||
|
||||
That makes it the best `v0` tool for “did this prompt/skill/model change obviously regress?”
|
||||
|
||||
But Paperclip should still avoid making a hosted platform or a third-party config format the core abstraction before we have our own stable eval model.
|
||||
|
||||
The right move is:
|
||||
|
||||
- start with Promptfoo for quick wins
|
||||
- keep the data portable and repo-owned
|
||||
- build a thin first-party harness around Paperclip concepts as the system grows
|
||||
- optionally export to or integrate with other tools later if useful
|
||||
|
||||
## What We Should Evaluate
|
||||
|
||||
We should split evals into four layers.
|
||||
|
||||
### Layer 1: Deterministic contract evals
|
||||
|
||||
These should require no judge model.
|
||||
|
||||
Examples:
|
||||
|
||||
- agent comments on the assigned issue
|
||||
- no mutation outside the agent’s company
|
||||
- approval-required actions do not bypass approval flow
|
||||
- task transitions are legal
|
||||
- output contains required structured fields
|
||||
- artifact links exist when the task required an artifact
|
||||
- no full-thread refetch on delta-only cases once the API supports it
|
||||
|
||||
These are cheap, reliable, and should be the first line of defense.
|
||||
|
||||
### Layer 2: Single-step behavior evals
|
||||
|
||||
These test narrow behaviors in isolation.
|
||||
|
||||
Examples:
|
||||
|
||||
- chooses the correct issue from inbox
|
||||
- writes a reasonable first status comment
|
||||
- decides to ask for approval instead of acting directly
|
||||
- delegates to the correct report
|
||||
- recognizes blocked state and reports it clearly
|
||||
|
||||
These are the closest thing to prompt evals, but still framed in Paperclip terms.
|
||||
|
||||
### Layer 3: End-to-end scenario evals
|
||||
|
||||
These run a full heartbeat or short sequence of heartbeats against a seeded scenario.
|
||||
|
||||
Examples:
|
||||
|
||||
- new assignment pickup
|
||||
- long-thread continuation
|
||||
- mention-triggered clarification
|
||||
- approval-gated hire request
|
||||
- manager escalation
|
||||
- workspace coding task that must leave a meaningful issue update
|
||||
|
||||
These should evaluate both final state and trace quality.
|
||||
|
||||
### Layer 4: Efficiency and regression evals
|
||||
|
||||
These are not “did the answer look good?” evals. They are “did we preserve quality while improving cost/latency?” evals.
|
||||
|
||||
Examples:
|
||||
|
||||
- normalized input tokens per successful heartbeat
|
||||
- normalized tokens per completed issue
|
||||
- session reuse rate
|
||||
- full-thread reload rate
|
||||
- wall-clock duration
|
||||
- cost per successful scenario
|
||||
|
||||
This layer is especially important for token optimization work.
|
||||
|
||||
## Core Design
|
||||
|
||||
## 1. Canonical object: `EvalCase`
|
||||
|
||||
Each eval case should define:
|
||||
|
||||
- scenario setup
|
||||
- target bundle(s)
|
||||
- execution mode
|
||||
- expected invariants
|
||||
- scoring rubric
|
||||
- tags/metadata
|
||||
|
||||
Suggested shape:
|
||||
|
||||
```ts
|
||||
type EvalCase = {
|
||||
id: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
setup: {
|
||||
fixture: string;
|
||||
agentId: string;
|
||||
trigger: "assignment" | "timer" | "on_demand" | "comment" | "approval";
|
||||
};
|
||||
inputs?: Record<string, unknown>;
|
||||
checks: {
|
||||
hard: HardCheck[];
|
||||
rubric?: RubricCheck[];
|
||||
pairwise?: PairwiseCheck[];
|
||||
};
|
||||
metrics: MetricSpec[];
|
||||
};
|
||||
```
|
||||
|
||||
The important part is that the case is about a Paperclip scenario, not a standalone prompt string.
|
||||
|
||||
## 2. Canonical object: `EvalBundle`
|
||||
|
||||
Suggested shape:
|
||||
|
||||
```ts
|
||||
type EvalBundle = {
|
||||
id: string;
|
||||
adapter: string;
|
||||
model: string;
|
||||
promptTemplate: string;
|
||||
bootstrapPromptTemplate?: string;
|
||||
skills: string[];
|
||||
flags?: Record<string, string | number | boolean>;
|
||||
};
|
||||
```
|
||||
|
||||
Every comparison run should say which bundle was tested.
|
||||
|
||||
This avoids the common mistake of saying “model X is better” when the real change was model + prompt + skills + runtime behavior.
|
||||
|
||||
## 3. Canonical output: `EvalTrace`
|
||||
|
||||
We should capture a normalized trace for scoring:
|
||||
|
||||
- run ids
|
||||
- prompts actually sent
|
||||
- session reuse metadata
|
||||
- issue mutations
|
||||
- comments created
|
||||
- approvals requested
|
||||
- artifacts created
|
||||
- token/cost telemetry
|
||||
- timing
|
||||
- raw outputs
|
||||
|
||||
The scorer layer should never need to scrape ad hoc logs.
|
||||
|
||||
## Scoring Framework
|
||||
|
||||
## 1. Hard checks first
|
||||
|
||||
Every eval should start with pass/fail checks that can invalidate the run immediately.
|
||||
|
||||
Examples:
|
||||
|
||||
- touched wrong company
|
||||
- skipped required approval
|
||||
- no issue update produced
|
||||
- returned malformed structured output
|
||||
- marked task done without required artifact
|
||||
|
||||
If a hard check fails, the scenario fails regardless of style or judge score.
|
||||
|
||||
## 2. Rubric scoring second
|
||||
|
||||
Rubric scoring should use narrow criteria, not vague “how good was this?” prompts.
|
||||
|
||||
Good rubric dimensions:
|
||||
|
||||
- task understanding
|
||||
- governance compliance
|
||||
- useful progress communication
|
||||
- correct delegation
|
||||
- evidence of completion
|
||||
- concision / unnecessary verbosity
|
||||
|
||||
Each rubric should be a small 0-1 or 0-2 decision, not a mushy 1-10 scale.
|
||||
|
||||
## 3. Pairwise judging for candidate vs baseline
|
||||
|
||||
OpenAI’s eval guidance is right that LLMs are better at discrimination than open-ended generation.
|
||||
|
||||
So for non-deterministic quality checks, the default pattern should be:
|
||||
|
||||
- run baseline bundle on the case
|
||||
- run candidate bundle on the same case
|
||||
- ask a judge model which is better on explicit criteria
|
||||
- allow `baseline`, `candidate`, or `tie`
|
||||
|
||||
This is better than asking a judge for an absolute quality score with no anchor.
|
||||
|
||||
## 4. Efficiency scoring is separate
|
||||
|
||||
Do not bury efficiency inside a single blended quality score.
|
||||
|
||||
Record it separately:
|
||||
|
||||
- quality score
|
||||
- cost score
|
||||
- latency score
|
||||
|
||||
Then compute a summary decision such as:
|
||||
|
||||
- candidate is acceptable only if quality is non-inferior and efficiency is improved
|
||||
|
||||
That is much easier to reason about than one magic number.
|
||||
|
||||
## Suggested Decision Rule
|
||||
|
||||
For PR gating:
|
||||
|
||||
1. No hard-check regressions.
|
||||
2. No significant regression on required scenario pass rate.
|
||||
3. No significant regression on key rubric dimensions.
|
||||
4. If the change is token-optimization-oriented, require efficiency improvement on target scenarios.
|
||||
|
||||
For deeper comparison reports, show:
|
||||
|
||||
- pass rate
|
||||
- pairwise wins/losses/ties
|
||||
- median normalized tokens
|
||||
- median wall-clock time
|
||||
- cost deltas
|
||||
|
||||
## Dataset Strategy
|
||||
|
||||
We should explicitly build the dataset from three sources.
|
||||
|
||||
### 1. Hand-authored seed cases
|
||||
|
||||
Start here.
|
||||
|
||||
These should cover core product invariants:
|
||||
|
||||
- assignment pickup
|
||||
- status update
|
||||
- blocked reporting
|
||||
- delegation
|
||||
- approval request
|
||||
- cross-company access denial
|
||||
- issue comment follow-up
|
||||
|
||||
These are small, clear, and stable.
|
||||
|
||||
### 2. Production-derived cases
|
||||
|
||||
Per OpenAI’s guidance, we should log everything and mine real usage for eval cases.
|
||||
|
||||
Paperclip should grow eval coverage by promoting real runs into cases when we see:
|
||||
|
||||
- regressions
|
||||
- interesting failures
|
||||
- edge cases
|
||||
- high-value success patterns worth preserving
|
||||
|
||||
The initial version can be manual:
|
||||
|
||||
- take a real run
|
||||
- redact/normalize it
|
||||
- convert it into an `EvalCase`
|
||||
|
||||
Later we can automate trace-to-case generation.
|
||||
|
||||
### 3. Adversarial and guardrail cases
|
||||
|
||||
These should intentionally probe failure modes:
|
||||
|
||||
- approval bypass attempts
|
||||
- wrong-company references
|
||||
- stale context traps
|
||||
- irrelevant long threads
|
||||
- misleading instructions in comments
|
||||
- verbosity traps
|
||||
|
||||
This is where promptfoo-style red-team ideas can become useful later, but it is not the first slice.
|
||||
|
||||
## Repo Layout
|
||||
|
||||
Recommended initial layout:
|
||||
|
||||
```text
|
||||
evals/
|
||||
README.md
|
||||
promptfoo/
|
||||
promptfooconfig.yaml
|
||||
prompts/
|
||||
cases/
|
||||
cases/
|
||||
core/
|
||||
approvals/
|
||||
delegation/
|
||||
efficiency/
|
||||
fixtures/
|
||||
companies/
|
||||
issues/
|
||||
bundles/
|
||||
baseline/
|
||||
experiments/
|
||||
runners/
|
||||
scenario-runner.ts
|
||||
compare-runner.ts
|
||||
scorers/
|
||||
hard/
|
||||
rubric/
|
||||
pairwise/
|
||||
judges/
|
||||
rubric-judge.ts
|
||||
pairwise-judge.ts
|
||||
lib/
|
||||
types.ts
|
||||
traces.ts
|
||||
metrics.ts
|
||||
reports/
|
||||
.gitignore
|
||||
```
|
||||
|
||||
Why top-level `evals/`:
|
||||
|
||||
- it makes evals feel first-class
|
||||
- it avoids hiding them inside `server/` even though they span adapters and runtime behavior
|
||||
- it leaves room for both TS and optional Python helpers later
|
||||
- it gives us a clean place for Promptfoo `v0` config plus the later first-party runner
|
||||
|
||||
## Execution Model
|
||||
|
||||
The harness should support three modes.
|
||||
|
||||
### Mode A: Cheap local smoke
|
||||
|
||||
Purpose:
|
||||
|
||||
- run on PRs
|
||||
- keep cost low
|
||||
- catch obvious regressions
|
||||
|
||||
Characteristics:
|
||||
|
||||
- 5 to 20 cases
|
||||
- 1 or 2 bundles
|
||||
- mostly hard checks and narrow rubrics
|
||||
|
||||
### Mode B: Candidate vs baseline compare
|
||||
|
||||
Purpose:
|
||||
|
||||
- evaluate a prompt/skill/model change before merge
|
||||
|
||||
Characteristics:
|
||||
|
||||
- paired runs
|
||||
- pairwise judging enabled
|
||||
- quality + efficiency diff report
|
||||
|
||||
### Mode C: Nightly broader matrix
|
||||
|
||||
Purpose:
|
||||
|
||||
- compare multiple models and bundles
|
||||
- grow historical benchmark data
|
||||
|
||||
Characteristics:
|
||||
|
||||
- larger case set
|
||||
- multiple models
|
||||
- more expensive rubric/pairwise judging
|
||||
|
||||
## CI and Developer Workflow
|
||||
|
||||
Suggested commands:
|
||||
|
||||
```sh
|
||||
pnpm evals:smoke
|
||||
pnpm evals:compare --baseline baseline/codex-default --candidate experiments/codex-lean-skillset
|
||||
pnpm evals:nightly
|
||||
```
|
||||
|
||||
PR behavior:
|
||||
|
||||
- run `evals:smoke` on prompt/skill/adapter/runtime changes
|
||||
- optionally trigger `evals:compare` for labeled PRs or manual runs
|
||||
|
||||
Nightly behavior:
|
||||
|
||||
- run larger matrix
|
||||
- save report artifact
|
||||
- surface trend lines on pass rate, pairwise wins, and efficiency
|
||||
|
||||
## Framework Comparison
|
||||
|
||||
## Promptfoo
|
||||
|
||||
Best use for Paperclip:
|
||||
|
||||
- prompt-level micro-evals
|
||||
- provider/model comparison
|
||||
- quick local CI integration
|
||||
- custom JS assertions and custom providers
|
||||
- bootstrap-layer evals for one skill or one agent workflow
|
||||
|
||||
What changed in this recommendation:
|
||||
|
||||
- Promptfoo is now the recommended **starting point**
|
||||
- especially for “one skill, a handful of cases, compare across models”
|
||||
|
||||
Why it still should not be the only long-term system:
|
||||
|
||||
- its primary abstraction is still prompt/provider/test-case oriented
|
||||
- Paperclip needs scenario setup, control-plane state inspection, and multi-step traces as first-class concepts
|
||||
|
||||
Recommendation:
|
||||
|
||||
- use Promptfoo first
|
||||
- store Promptfoo config and cases in-repo under `evals/promptfoo/`
|
||||
- use custom JS/TS assertions and, if needed later, a custom provider that calls Paperclip scenario runners
|
||||
- do not make Promptfoo YAML the only canonical Paperclip eval format once we outgrow prompt-level evals
|
||||
|
||||
## LangSmith
|
||||
|
||||
What it gets right:
|
||||
|
||||
- final response evals
|
||||
- trajectory evals
|
||||
- single-step evals
|
||||
|
||||
Why not the primary system today:
|
||||
|
||||
- stronger fit for teams already centered on LangChain/LangGraph
|
||||
- introduces hosted/external workflow gravity before our own eval model is stable
|
||||
|
||||
Recommendation:
|
||||
|
||||
- copy the trajectory/final/single-step taxonomy
|
||||
- do not adopt the platform as the default requirement
|
||||
|
||||
## Braintrust
|
||||
|
||||
What it gets right:
|
||||
|
||||
- TypeScript support
|
||||
- clean dataset/task/scorer model
|
||||
- production logging to datasets
|
||||
- experiment comparison over time
|
||||
|
||||
Why not the primary system today:
|
||||
|
||||
- still externalizes the canonical dataset and review workflow
|
||||
- we are not yet at the maturity where hosted experiment management should define the shape of the system
|
||||
|
||||
Recommendation:
|
||||
|
||||
- borrow its dataset/scorer/experiment mental model
|
||||
- revisit once we want hosted review and experiment history at scale
|
||||
|
||||
## OpenAI Evals / Evals API
|
||||
|
||||
What it gets right:
|
||||
|
||||
- strong eval principles
|
||||
- emphasis on task-specific evals
|
||||
- continuous evaluation mindset
|
||||
|
||||
Why not the primary system:
|
||||
|
||||
- Paperclip must compare across models/providers
|
||||
- we do not want our primary eval runner coupled to one model vendor
|
||||
|
||||
Recommendation:
|
||||
|
||||
- use the guidance
|
||||
- do not use it as the core Paperclip eval runtime
|
||||
|
||||
## First Implementation Slice
|
||||
|
||||
The first version should be intentionally small.
|
||||
|
||||
## Phase 0: Promptfoo bootstrap
|
||||
|
||||
Build:
|
||||
|
||||
- `evals/promptfoo/promptfooconfig.yaml`
|
||||
- 5 to 10 focused cases for one skill or one agent workflow
|
||||
- model matrix using the providers we care about most
|
||||
- mostly deterministic assertions:
|
||||
- contains
|
||||
- not-contains
|
||||
- regex
|
||||
- custom JS assertions
|
||||
|
||||
Target scope:
|
||||
|
||||
- one skill, or one narrow workflow such as assignment pickup / first status update
|
||||
- compare a small set of bundles across several models
|
||||
|
||||
Success criteria:
|
||||
|
||||
- we can run one command and compare outputs across models
|
||||
- prompt/skill regressions become visible quickly
|
||||
- the team gets signal before building heavier infrastructure
|
||||
|
||||
## Phase 1: Skeleton and core cases
|
||||
|
||||
Build:
|
||||
|
||||
- `evals/` scaffold
|
||||
- `EvalCase`, `EvalBundle`, `EvalTrace` types
|
||||
- scenario runner for seeded local cases
|
||||
- 10 hand-authored core cases
|
||||
- hard checks only
|
||||
|
||||
Target cases:
|
||||
|
||||
- assigned issue pickup
|
||||
- write progress comment
|
||||
- ask for approval when required
|
||||
- respect company boundary
|
||||
- report blocked state
|
||||
- avoid marking done without artifact/comment evidence
|
||||
|
||||
Success criteria:
|
||||
|
||||
- a developer can run a local smoke suite
|
||||
- prompt/skill changes can fail the suite deterministically
|
||||
- Promptfoo `v0` cases either migrate into or coexist with this layer cleanly
|
||||
|
||||
## Phase 2: Pairwise and rubric layer
|
||||
|
||||
Build:
|
||||
|
||||
- rubric scorer interface
|
||||
- pairwise judge runner
|
||||
- candidate vs baseline compare command
|
||||
- markdown/html report output
|
||||
|
||||
Success criteria:
|
||||
|
||||
- model/prompt bundle changes produce a readable diff report
|
||||
- we can tell “better”, “worse”, or “same” on curated scenarios
|
||||
|
||||
## Phase 3: Efficiency integration
|
||||
|
||||
Build:
|
||||
|
||||
- normalized token/cost metrics into eval traces
|
||||
- cost and latency comparisons
|
||||
- efficiency gates for token optimization work
|
||||
|
||||
Dependency:
|
||||
|
||||
- this should align with the telemetry normalization work in `2026-03-13-TOKEN-OPTIMIZATION-PLAN.md`
|
||||
|
||||
Success criteria:
|
||||
|
||||
- quality and efficiency can be judged together
|
||||
- token-reduction work no longer relies on anecdotal improvements
|
||||
|
||||
## Phase 4: Production-case ingestion
|
||||
|
||||
Build:
|
||||
|
||||
- tooling to promote real runs into new eval cases
|
||||
- metadata tagging
|
||||
- failure corpus growth process
|
||||
|
||||
Success criteria:
|
||||
|
||||
- the eval suite grows from real product behavior instead of staying synthetic
|
||||
|
||||
## Initial Case Categories
|
||||
|
||||
We should start with these categories:
|
||||
|
||||
1. `core.assignment_pickup`
|
||||
2. `core.progress_update`
|
||||
3. `core.blocked_reporting`
|
||||
4. `governance.approval_required`
|
||||
5. `governance.company_boundary`
|
||||
6. `delegation.correct_report`
|
||||
7. `threads.long_context_followup`
|
||||
8. `efficiency.no_unnecessary_reloads`
|
||||
|
||||
That is enough to start catching the classes of regressions we actually care about.
|
||||
|
||||
## Important Guardrails
|
||||
|
||||
### 1. Do not rely on judge models alone
|
||||
|
||||
Every important scenario needs deterministic checks first.
|
||||
|
||||
### 2. Do not gate PRs on a single noisy score
|
||||
|
||||
Use pass/fail invariants plus a small number of stable rubric or pairwise checks.
|
||||
|
||||
### 3. Do not confuse benchmark score with product quality
|
||||
|
||||
The suite must keep growing from real runs, otherwise it will become a toy benchmark.
|
||||
|
||||
### 4. Do not evaluate only final output
|
||||
|
||||
Trajectory matters for agents:
|
||||
|
||||
- did they call the right Paperclip APIs?
|
||||
- did they ask for approval?
|
||||
- did they communicate progress?
|
||||
- did they choose the right issue?
|
||||
|
||||
### 5. Do not make the framework vendor-shaped
|
||||
|
||||
Our eval model should survive changes in:
|
||||
|
||||
- judge provider
|
||||
- candidate provider
|
||||
- adapter implementation
|
||||
- hosted tooling choices
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Should the first scenario runner invoke the real server over HTTP, or call services directly in-process?
|
||||
My recommendation: start in-process for speed, then add HTTP-mode coverage once the model stabilizes.
|
||||
|
||||
2. Should we support Python scorers in v1?
|
||||
My recommendation: no. Keep v1 all-TypeScript.
|
||||
|
||||
3. Should we commit baseline outputs?
|
||||
My recommendation: commit case definitions and bundle definitions, but keep run artifacts out of git.
|
||||
|
||||
4. Should we add hosted experiment tracking immediately?
|
||||
My recommendation: no. Revisit after the local harness proves useful.
|
||||
|
||||
## Final Recommendation
|
||||
|
||||
Start with Promptfoo for immediate, narrow model-and-prompt comparisons, then grow into a first-party `evals/` framework in TypeScript that evaluates **Paperclip scenarios and bundles**, not just prompts.
|
||||
|
||||
Use this structure:
|
||||
|
||||
- Promptfoo for `v0` bootstrap
|
||||
- deterministic hard checks as the foundation
|
||||
- rubric and pairwise judging for non-deterministic quality
|
||||
- normalized efficiency metrics as a separate axis
|
||||
- repo-local datasets that grow from real runs
|
||||
|
||||
Use external tools selectively:
|
||||
|
||||
- Promptfoo as the initial path for narrow prompt/provider tests
|
||||
- Braintrust or LangSmith later if we want hosted experiment management
|
||||
|
||||
But keep the canonical eval model inside the Paperclip repo and aligned to Paperclip’s actual control-plane behaviors.
|
||||
186
doc/plans/2026-03-13-paperclip-skill-tightening-plan.md
Normal file
186
doc/plans/2026-03-13-paperclip-skill-tightening-plan.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# Paperclip Skill Tightening Plan
|
||||
|
||||
## Status
|
||||
|
||||
Deferred follow-up. Do not include in the current token-optimization PR beyond documenting the plan.
|
||||
|
||||
## Why This Is Deferred
|
||||
|
||||
The `paperclip` skill is part of the critical control-plane safety surface. Tightening it may reduce fresh-session token use, but it also carries prompt-regression risk. We do not yet have evals that would let us safely prove behavior preservation across assignment handling, checkout rules, comment etiquette, approval workflows, and escalation paths.
|
||||
|
||||
The current PR should ship the lower-risk infrastructure wins first:
|
||||
|
||||
- telemetry normalization
|
||||
- safe session reuse
|
||||
- incremental issue/comment context
|
||||
- bootstrap versus heartbeat prompt separation
|
||||
- Codex worktree isolation
|
||||
|
||||
## Current Problem
|
||||
|
||||
Fresh runs still spend substantial input tokens even after the context-path fixes. The remaining large startup cost appears to come from loading the full `paperclip` skill and related instruction surface into context at run start.
|
||||
|
||||
The skill currently mixes three kinds of content in one file:
|
||||
|
||||
- hot-path heartbeat procedure used on nearly every run
|
||||
- critical policy and safety invariants
|
||||
- rare workflow/reference material that most runs do not need
|
||||
|
||||
That structure is safe but expensive.
|
||||
|
||||
## Goals
|
||||
|
||||
- reduce first-run instruction tokens without weakening agent safety
|
||||
- preserve all current Paperclip control-plane capabilities
|
||||
- keep common heartbeat behavior explicit and easy for agents to follow
|
||||
- move rare workflows and reference material out of the hot path
|
||||
- create a structure that can later be evaluated systematically
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- changing Paperclip API semantics
|
||||
- removing required governance rules
|
||||
- deleting rare workflows
|
||||
- changing agent defaults in the current PR
|
||||
|
||||
## Recommended Direction
|
||||
|
||||
### 1. Split Hot Path From Lookup Material
|
||||
|
||||
Restructure the skill into:
|
||||
|
||||
- an always-loaded core section for the common heartbeat loop
|
||||
- on-demand material for infrequent workflows and deep reference
|
||||
|
||||
The core should cover only what is needed on nearly every wake:
|
||||
|
||||
- auth and required headers
|
||||
- inbox-first assignment retrieval
|
||||
- mandatory checkout behavior
|
||||
- `heartbeat-context` first
|
||||
- incremental comment retrieval rules
|
||||
- mention/self-assign exception
|
||||
- blocked-task dedup
|
||||
- status/comment/release expectations before exit
|
||||
|
||||
### 2. Normalize The Skill Around One Canonical Procedure
|
||||
|
||||
The same rules are currently expressed multiple times across:
|
||||
|
||||
- heartbeat steps
|
||||
- critical rules
|
||||
- endpoint reference
|
||||
- workflow examples
|
||||
|
||||
Refactor so each operational fact has one primary home:
|
||||
|
||||
- procedure
|
||||
- invariant list
|
||||
- appendix/reference
|
||||
|
||||
This reduces prompt weight and lowers the chance of internal instruction drift.
|
||||
|
||||
### 3. Compress Prose Into High-Signal Instruction Forms
|
||||
|
||||
Rewrite the hot path using compact operational forms:
|
||||
|
||||
- short ordered checklist
|
||||
- flat invariant list
|
||||
- minimal examples only where ambiguity would be risky
|
||||
|
||||
Reduce:
|
||||
|
||||
- narrative explanation
|
||||
- repeated warnings already covered elsewhere
|
||||
- large example payloads for common operations
|
||||
- long endpoint matrices in the main body
|
||||
|
||||
### 4. Move Rare Workflows Behind Explicit Triggers
|
||||
|
||||
These workflows should remain available but should not dominate fresh-run context:
|
||||
|
||||
- OpenClaw invite flow
|
||||
- project setup flow
|
||||
- planning `<plan/>` writeback flow
|
||||
- instructions-path update flow
|
||||
- detailed link-formatting examples
|
||||
|
||||
Recommended approach:
|
||||
|
||||
- keep a short pointer in the main skill
|
||||
- move detailed procedures into sibling skills or referenced docs that agents read only when needed
|
||||
|
||||
### 5. Separate Policy From Reference
|
||||
|
||||
The skill should distinguish:
|
||||
|
||||
- mandatory operating rules
|
||||
- endpoint lookup/reference
|
||||
- business-process playbooks
|
||||
|
||||
That separation makes it easier to evaluate prompt changes later and lets adapters or orchestration choose what must always be loaded.
|
||||
|
||||
## Proposed Target Structure
|
||||
|
||||
1. Purpose and authentication
|
||||
2. Compact heartbeat procedure
|
||||
3. Hard invariants
|
||||
4. Required comment/update style
|
||||
5. Triggered workflow index
|
||||
6. Appendix/reference
|
||||
|
||||
## Rollout Plan
|
||||
|
||||
### Phase 1. Inventory And Measure
|
||||
|
||||
- annotate the current skill by section and estimate token weight
|
||||
- identify which sections are truly hot-path versus rare
|
||||
- capture representative runs to compare before/after prompt size and behavior
|
||||
|
||||
### Phase 2. Structural Refactor Without Semantic Changes
|
||||
|
||||
- rewrite the main skill into the target structure
|
||||
- preserve all existing rules and capabilities
|
||||
- move rare workflow details into referenced companion material
|
||||
- keep wording changes conservative
|
||||
|
||||
### Phase 3. Validate Against Real Scenarios
|
||||
|
||||
Run scenario checks for:
|
||||
|
||||
- normal assigned heartbeat
|
||||
- comment-triggered wake
|
||||
- blocked-task dedup behavior
|
||||
- approval-resolution wake
|
||||
- delegation/subtask creation
|
||||
- board handoff back to user
|
||||
- plan-request handling
|
||||
|
||||
### Phase 4. Decide Default Loading Strategy
|
||||
|
||||
After validation, decide whether:
|
||||
|
||||
- the entire main skill still loads by default, or
|
||||
- only the compact core loads by default and rare sections are fetched on demand
|
||||
|
||||
Do not change this loading policy without validation.
|
||||
|
||||
## Risks
|
||||
|
||||
- prompt degradation on control-plane safety rules
|
||||
- agents forgetting rare but important workflows
|
||||
- accidental removal of repeated wording that was carrying useful behavior
|
||||
- introducing ambiguous instruction precedence between the core skill and companion materials
|
||||
|
||||
## Preconditions Before Implementation
|
||||
|
||||
- define acceptance scenarios for control-plane correctness
|
||||
- add at least lightweight eval or scripted scenario coverage for key Paperclip flows
|
||||
- confirm how adapter/bootstrap layering should load skill content versus references
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- materially lower first-run input tokens for Paperclip-coordinated agents
|
||||
- no regression in checkout discipline, issue updates, blocked handling, or delegation
|
||||
- no increase in malformed API usage or ownership mistakes
|
||||
- agents still complete rare workflows correctly when explicitly asked
|
||||
699
doc/plans/2026-03-13-plugin-kitchen-sink-example.md
Normal file
699
doc/plans/2026-03-13-plugin-kitchen-sink-example.md
Normal file
@@ -0,0 +1,699 @@
|
||||
# Kitchen Sink Plugin Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Add a new first-party example plugin, `Kitchen Sink (Example)`, that demonstrates every currently implemented Paperclip plugin API surface in one place.
|
||||
|
||||
This plugin is meant to be:
|
||||
|
||||
- a living reference implementation for contributors
|
||||
- a manual test harness for the plugin runtime
|
||||
- a discoverable demo of what plugins can actually do today
|
||||
|
||||
It is not meant to be a polished end-user product plugin.
|
||||
|
||||
## Why
|
||||
|
||||
The current plugin system has a real API surface, but it is spread across:
|
||||
|
||||
- SDK docs
|
||||
- SDK types
|
||||
- plugin spec prose
|
||||
- two example plugins that each show only a narrow slice
|
||||
|
||||
That makes it hard to answer basic questions like:
|
||||
|
||||
- what can plugins render?
|
||||
- what can plugin workers actually do?
|
||||
- which surfaces are real versus aspirational?
|
||||
- how should a new plugin be structured in this repo?
|
||||
|
||||
The kitchen-sink plugin should answer those questions by example.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
The plugin is successful if a contributor can install it and, without reading the SDK first, discover and exercise the current plugin runtime surface area from inside Paperclip.
|
||||
|
||||
Concretely:
|
||||
|
||||
- it installs from the bundled examples list
|
||||
- it exposes at least one demo for every implemented worker API surface
|
||||
- it exposes at least one demo for every host-mounted UI surface
|
||||
- it clearly labels local-only / trusted-only demos
|
||||
- it is safe enough for local development by default
|
||||
- it doubles as a regression harness for plugin runtime changes
|
||||
|
||||
## Constraints
|
||||
|
||||
- Keep it instance-installed, not company-installed.
|
||||
- Treat this as a trusted/local example plugin.
|
||||
- Do not rely on cloud-safe runtime assumptions.
|
||||
- Avoid destructive defaults.
|
||||
- Avoid irreversible mutations unless they are clearly labeled and easy to undo.
|
||||
|
||||
## Source Of Truth For This Plan
|
||||
|
||||
This plan is based on the currently implemented SDK/types/runtime, not only the long-horizon spec.
|
||||
|
||||
Primary references:
|
||||
|
||||
- `packages/plugins/sdk/README.md`
|
||||
- `packages/plugins/sdk/src/types.ts`
|
||||
- `packages/plugins/sdk/src/ui/types.ts`
|
||||
- `packages/shared/src/constants.ts`
|
||||
- `packages/shared/src/types/plugin.ts`
|
||||
|
||||
## Current Surface Inventory
|
||||
|
||||
### Worker/runtime APIs to demonstrate
|
||||
|
||||
These are the concrete `ctx` clients currently exposed by the SDK:
|
||||
|
||||
- `ctx.config`
|
||||
- `ctx.events`
|
||||
- `ctx.jobs`
|
||||
- `ctx.launchers`
|
||||
- `ctx.http`
|
||||
- `ctx.secrets`
|
||||
- `ctx.assets`
|
||||
- `ctx.activity`
|
||||
- `ctx.state`
|
||||
- `ctx.entities`
|
||||
- `ctx.projects`
|
||||
- `ctx.companies`
|
||||
- `ctx.issues`
|
||||
- `ctx.agents`
|
||||
- `ctx.goals`
|
||||
- `ctx.data`
|
||||
- `ctx.actions`
|
||||
- `ctx.streams`
|
||||
- `ctx.tools`
|
||||
- `ctx.metrics`
|
||||
- `ctx.logger`
|
||||
|
||||
### UI surfaces to demonstrate
|
||||
|
||||
Surfaces defined in the SDK:
|
||||
|
||||
- `page`
|
||||
- `settingsPage`
|
||||
- `dashboardWidget`
|
||||
- `sidebar`
|
||||
- `sidebarPanel`
|
||||
- `detailTab`
|
||||
- `taskDetailView`
|
||||
- `projectSidebarItem`
|
||||
- `toolbarButton`
|
||||
- `contextMenuItem`
|
||||
- `commentAnnotation`
|
||||
- `commentContextMenuItem`
|
||||
|
||||
### Current host confidence
|
||||
|
||||
Confirmed or strongly indicated as mounted in the current app:
|
||||
|
||||
- `page`
|
||||
- `settingsPage`
|
||||
- `dashboardWidget`
|
||||
- `detailTab`
|
||||
- `projectSidebarItem`
|
||||
- comment surfaces
|
||||
- launcher infrastructure
|
||||
|
||||
Need explicit validation before claiming full demo coverage:
|
||||
|
||||
- `sidebar`
|
||||
- `sidebarPanel`
|
||||
- `taskDetailView`
|
||||
- `toolbarButton` as direct slot, distinct from launcher placement
|
||||
- `contextMenuItem` as direct slot, distinct from comment menu and launcher placement
|
||||
|
||||
The implementation should keep a small validation checklist for these before we call the plugin "complete".
|
||||
|
||||
## Plugin Concept
|
||||
|
||||
The plugin should be named:
|
||||
|
||||
- display name: `Kitchen Sink (Example)`
|
||||
- package: `@paperclipai/plugin-kitchen-sink-example`
|
||||
- plugin id: `paperclip.kitchen-sink-example` or `paperclip-kitchen-sink-example`
|
||||
|
||||
Recommendation: use `paperclip-kitchen-sink-example` to match current in-repo example naming style.
|
||||
|
||||
Category mix:
|
||||
|
||||
- `ui`
|
||||
- `automation`
|
||||
- `workspace`
|
||||
- `connector`
|
||||
|
||||
That is intentionally broad because the point is coverage.
|
||||
|
||||
## UX Shape
|
||||
|
||||
The plugin should have one main full-page demo console plus smaller satellites on other surfaces.
|
||||
|
||||
### 1. Plugin page
|
||||
|
||||
Primary route: the plugin `page` surface should be the central dashboard for all demos.
|
||||
|
||||
Recommended page sections:
|
||||
|
||||
- `Overview`
|
||||
- what this plugin demonstrates
|
||||
- current capabilities granted
|
||||
- current host context
|
||||
- `UI Surfaces`
|
||||
- links explaining where each other surface should appear
|
||||
- `Data + Actions`
|
||||
- buttons and forms for bridge-driven worker demos
|
||||
- `Events + Streams`
|
||||
- emit event
|
||||
- watch event log
|
||||
- stream demo output
|
||||
- `Paperclip Domain APIs`
|
||||
- companies
|
||||
- projects/workspaces
|
||||
- issues
|
||||
- goals
|
||||
- agents
|
||||
- `Local Workspace + Process`
|
||||
- file listing
|
||||
- file read/write scratch area
|
||||
- child process demo
|
||||
- `Jobs + Webhooks + Tools`
|
||||
- job status
|
||||
- webhook URL and recent deliveries
|
||||
- declared tools
|
||||
- `State + Entities + Assets`
|
||||
- scoped state editor
|
||||
- plugin entity inspector
|
||||
- upload/generated asset demo
|
||||
- `Observability`
|
||||
- metrics written
|
||||
- activity log samples
|
||||
- latest worker logs
|
||||
|
||||
### 2. Dashboard widget
|
||||
|
||||
A compact widget on the main dashboard should show:
|
||||
|
||||
- plugin health
|
||||
- count of demos exercised
|
||||
- recent event/stream activity
|
||||
- shortcut to the full plugin page
|
||||
|
||||
### 3. Project sidebar item
|
||||
|
||||
Add a `Kitchen Sink` link under each project that deep-links into a project-scoped plugin tab.
|
||||
|
||||
### 4. Detail tabs
|
||||
|
||||
Use detail tabs to demonstrate entity-context rendering on:
|
||||
|
||||
- `project`
|
||||
- `issue`
|
||||
- `agent`
|
||||
- `goal`
|
||||
|
||||
Each tab should show:
|
||||
|
||||
- the host context it received
|
||||
- the relevant entity fetch via worker bridge
|
||||
- one small action scoped to that entity
|
||||
|
||||
### 5. Comment surfaces
|
||||
|
||||
Use issue comment demos to prove comment-specific extension points:
|
||||
|
||||
- `commentAnnotation`
|
||||
- render parsed metadata below each comment
|
||||
- show comment id, issue id, and a small derived status
|
||||
- `commentContextMenuItem`
|
||||
- add a menu action like `Copy Context To Kitchen Sink`
|
||||
- action writes a plugin entity or state record for later inspection
|
||||
|
||||
### 6. Settings page
|
||||
|
||||
Custom `settingsPage` should be intentionally simple and operational:
|
||||
|
||||
- `About`
|
||||
- `Danger / Trust Model`
|
||||
- demo toggles
|
||||
- local process defaults
|
||||
- workspace scratch-path behavior
|
||||
- secret reference inputs
|
||||
- event/job/webhook sample config
|
||||
|
||||
This plugin should also keep the generic plugin settings `Status` tab useful by writing health, logs, and metrics.
|
||||
|
||||
## Feature Matrix
|
||||
|
||||
Each implemented worker API should have a visible demo.
|
||||
|
||||
### `ctx.config`
|
||||
|
||||
Demo:
|
||||
|
||||
- read live config
|
||||
- show config JSON
|
||||
- react to config changes without restart where possible
|
||||
|
||||
### `ctx.events`
|
||||
|
||||
Demos:
|
||||
|
||||
- emit a plugin event
|
||||
- subscribe to plugin events
|
||||
- subscribe to a core Paperclip event such as `issue.created`
|
||||
- show recent received events in a timeline
|
||||
|
||||
### `ctx.jobs`
|
||||
|
||||
Demos:
|
||||
|
||||
- one scheduled heartbeat-style demo job
|
||||
- one manual run button from the UI if host supports manual job trigger
|
||||
- show last run result and timestamps
|
||||
|
||||
### `ctx.launchers`
|
||||
|
||||
Demos:
|
||||
|
||||
- declare launchers in manifest
|
||||
- optionally register one runtime launcher from the worker
|
||||
- show launcher metadata on the plugin page
|
||||
|
||||
### `ctx.http`
|
||||
|
||||
Demo:
|
||||
|
||||
- make a simple outbound GET request to a safe endpoint
|
||||
- show status code, latency, and JSON result
|
||||
|
||||
Recommendation: default to a Paperclip-local endpoint or a stable public echo endpoint to avoid flaky docs.
|
||||
|
||||
### `ctx.secrets`
|
||||
|
||||
Demo:
|
||||
|
||||
- operator enters a secret reference in config
|
||||
- plugin resolves it on demand
|
||||
- UI only shows masked result length / success status, never raw secret
|
||||
|
||||
### `ctx.assets`
|
||||
|
||||
Demos:
|
||||
|
||||
- generate a text asset from the UI
|
||||
- optionally upload a tiny JSON blob or screenshot-like text file
|
||||
- show returned asset URL
|
||||
|
||||
### `ctx.activity`
|
||||
|
||||
Demo:
|
||||
|
||||
- button to write a plugin activity log entry against current company/entity
|
||||
|
||||
### `ctx.state`
|
||||
|
||||
Demos:
|
||||
|
||||
- instance-scoped state
|
||||
- company-scoped state
|
||||
- project-scoped state
|
||||
- issue-scoped state
|
||||
- delete/reset controls
|
||||
|
||||
Use a small state inspector/editor on the plugin page.
|
||||
|
||||
### `ctx.entities`
|
||||
|
||||
Demos:
|
||||
|
||||
- create plugin-owned sample records
|
||||
- list/filter them
|
||||
- show one realistic use case such as "copied comments" or "demo sync records"
|
||||
|
||||
### `ctx.projects`
|
||||
|
||||
Demos:
|
||||
|
||||
- list projects
|
||||
- list project workspaces
|
||||
- resolve primary workspace
|
||||
- resolve workspace for issue
|
||||
|
||||
### `ctx.companies`
|
||||
|
||||
Demo:
|
||||
|
||||
- list companies and show current selected company
|
||||
|
||||
### `ctx.issues`
|
||||
|
||||
Demos:
|
||||
|
||||
- list issues in current company
|
||||
- create issue
|
||||
- update issue status/title
|
||||
- list comments
|
||||
- create comment
|
||||
|
||||
### `ctx.agents`
|
||||
|
||||
Demos:
|
||||
|
||||
- list agents
|
||||
- invoke one agent with a test prompt
|
||||
- pause/resume where safe
|
||||
|
||||
Agent mutation controls should be behind an explicit warning.
|
||||
|
||||
### `ctx.agents.sessions`
|
||||
|
||||
Demos:
|
||||
|
||||
- create agent chat session
|
||||
- send message
|
||||
- stream events back to the UI
|
||||
- close session
|
||||
|
||||
This is a strong candidate for the best "wow" demo on the plugin page.
|
||||
|
||||
### `ctx.goals`
|
||||
|
||||
Demos:
|
||||
|
||||
- list goals
|
||||
- create goal
|
||||
- update status/title
|
||||
|
||||
### `ctx.data`
|
||||
|
||||
Use throughout the plugin for all read-side bridge demos.
|
||||
|
||||
### `ctx.actions`
|
||||
|
||||
Use throughout the plugin for all mutation-side bridge demos.
|
||||
|
||||
### `ctx.streams`
|
||||
|
||||
Demos:
|
||||
|
||||
- live event log stream
|
||||
- token-style stream from an agent session relay
|
||||
- fake progress stream for a long-running action
|
||||
|
||||
### `ctx.tools`
|
||||
|
||||
Demos:
|
||||
|
||||
- declare 2-3 simple agent tools
|
||||
- tool 1: echo/diagnostics
|
||||
- tool 2: project/workspace summary
|
||||
- tool 3: create issue or write plugin state
|
||||
|
||||
The plugin page should list declared tools and show example input payloads.
|
||||
|
||||
### `ctx.metrics`
|
||||
|
||||
Demo:
|
||||
|
||||
- write a sample metric on each major demo action
|
||||
- surface a small recent metrics table in the plugin page
|
||||
|
||||
### `ctx.logger`
|
||||
|
||||
Demo:
|
||||
|
||||
- every action logs structured entries
|
||||
- plugin settings `Status` page then doubles as the log viewer
|
||||
|
||||
## Local Workspace And Process Demos
|
||||
|
||||
The plugin SDK intentionally leaves file/process operations to the plugin itself once it has workspace metadata.
|
||||
|
||||
The kitchen-sink plugin should demonstrate that explicitly.
|
||||
|
||||
### Workspace demos
|
||||
|
||||
- list files from a selected workspace
|
||||
- read a file
|
||||
- write to a plugin-owned scratch file
|
||||
- optionally search files with `rg` if available
|
||||
|
||||
### Process demos
|
||||
|
||||
- run a short-lived command like `pwd`, `ls`, or `git status`
|
||||
- stream stdout/stderr back to UI
|
||||
- show exit code and timing
|
||||
|
||||
Important safeguards:
|
||||
|
||||
- default commands must be read-only
|
||||
- no shell interpolation from arbitrary free-form input in v1
|
||||
- provide a curated command list or a strongly validated command form
|
||||
- clearly label this area as local-only and trusted-only
|
||||
|
||||
## Proposed Manifest Coverage
|
||||
|
||||
The plugin should aim to declare:
|
||||
|
||||
- `page`
|
||||
- `settingsPage`
|
||||
- `dashboardWidget`
|
||||
- `detailTab` for `project`, `issue`, `agent`, `goal`
|
||||
- `projectSidebarItem`
|
||||
- `commentAnnotation`
|
||||
- `commentContextMenuItem`
|
||||
|
||||
Then, after host validation, add if supported:
|
||||
|
||||
- `sidebar`
|
||||
- `sidebarPanel`
|
||||
- `taskDetailView`
|
||||
- `toolbarButton`
|
||||
- `contextMenuItem`
|
||||
|
||||
It should also declare one or more `ui.launchers` entries to exercise launcher behavior independently of slot rendering.
|
||||
|
||||
## Proposed Package Layout
|
||||
|
||||
New package:
|
||||
|
||||
- `packages/plugins/examples/plugin-kitchen-sink-example/`
|
||||
|
||||
Expected files:
|
||||
|
||||
- `package.json`
|
||||
- `README.md`
|
||||
- `tsconfig.json`
|
||||
- `src/index.ts`
|
||||
- `src/manifest.ts`
|
||||
- `src/worker.ts`
|
||||
- `src/ui/index.tsx`
|
||||
- `src/ui/components/...`
|
||||
- `src/ui/hooks/...`
|
||||
- `src/lib/...`
|
||||
- optional `scripts/build-ui.mjs` if UI bundling needs esbuild
|
||||
|
||||
## Proposed Internal Architecture
|
||||
|
||||
### Worker modules
|
||||
|
||||
Recommended split:
|
||||
|
||||
- `src/worker.ts`
|
||||
- plugin definition and wiring
|
||||
- `src/worker/data.ts`
|
||||
- `ctx.data.register(...)`
|
||||
- `src/worker/actions.ts`
|
||||
- `ctx.actions.register(...)`
|
||||
- `src/worker/events.ts`
|
||||
- event subscriptions and event log buffer
|
||||
- `src/worker/jobs.ts`
|
||||
- scheduled job handlers
|
||||
- `src/worker/tools.ts`
|
||||
- tool declarations and handlers
|
||||
- `src/worker/local-runtime.ts`
|
||||
- file/process demos
|
||||
- `src/worker/demo-store.ts`
|
||||
- helpers for state/entities/assets/metrics
|
||||
|
||||
### UI modules
|
||||
|
||||
Recommended split:
|
||||
|
||||
- `src/ui/index.tsx`
|
||||
- exported slot components
|
||||
- `src/ui/page/KitchenSinkPage.tsx`
|
||||
- `src/ui/settings/KitchenSinkSettingsPage.tsx`
|
||||
- `src/ui/widgets/KitchenSinkDashboardWidget.tsx`
|
||||
- `src/ui/tabs/ProjectKitchenSinkTab.tsx`
|
||||
- `src/ui/tabs/IssueKitchenSinkTab.tsx`
|
||||
- `src/ui/tabs/AgentKitchenSinkTab.tsx`
|
||||
- `src/ui/tabs/GoalKitchenSinkTab.tsx`
|
||||
- `src/ui/comments/KitchenSinkCommentAnnotation.tsx`
|
||||
- `src/ui/comments/KitchenSinkCommentMenuItem.tsx`
|
||||
- `src/ui/shared/...`
|
||||
|
||||
## Configuration Schema
|
||||
|
||||
The plugin should have a substantial but understandable `instanceConfigSchema`.
|
||||
|
||||
Recommended config fields:
|
||||
|
||||
- `enableDangerousDemos`
|
||||
- `enableWorkspaceDemos`
|
||||
- `enableProcessDemos`
|
||||
- `showSidebarEntry`
|
||||
- `showSidebarPanel`
|
||||
- `showProjectSidebarItem`
|
||||
- `showCommentAnnotation`
|
||||
- `showCommentContextMenuItem`
|
||||
- `showToolbarLauncher`
|
||||
- `defaultDemoCompanyId` optional
|
||||
- `secretRefExample`
|
||||
- `httpDemoUrl`
|
||||
- `processAllowedCommands`
|
||||
- `workspaceScratchSubdir`
|
||||
|
||||
Defaults should keep risky behavior off.
|
||||
|
||||
## Safety Defaults
|
||||
|
||||
Default posture:
|
||||
|
||||
- UI and read-only demos on
|
||||
- mutating domain demos on but explicitly labeled
|
||||
- process demos off by default
|
||||
- no arbitrary shell input by default
|
||||
- no raw secret rendering ever
|
||||
|
||||
## Phased Build Plan
|
||||
|
||||
### Phase 1: Core plugin skeleton
|
||||
|
||||
- scaffold package
|
||||
- add manifest, worker, UI entrypoints
|
||||
- add README
|
||||
- make it appear in bundled examples list
|
||||
|
||||
### Phase 2: Core, confirmed UI surfaces
|
||||
|
||||
- plugin page
|
||||
- settings page
|
||||
- dashboard widget
|
||||
- project sidebar item
|
||||
- detail tabs
|
||||
|
||||
### Phase 3: Core worker APIs
|
||||
|
||||
- config
|
||||
- state
|
||||
- entities
|
||||
- companies/projects/issues/goals
|
||||
- data/actions
|
||||
- metrics/logger/activity
|
||||
|
||||
### Phase 4: Real-time and automation APIs
|
||||
|
||||
- streams
|
||||
- events
|
||||
- jobs
|
||||
- webhooks
|
||||
- agent sessions
|
||||
- tools
|
||||
|
||||
### Phase 5: Local trusted runtime demos
|
||||
|
||||
- workspace file demos
|
||||
- child process demos
|
||||
- guarded by config
|
||||
|
||||
### Phase 6: Secondary UI surfaces
|
||||
|
||||
- comment annotation
|
||||
- comment context menu item
|
||||
- launchers
|
||||
|
||||
### Phase 7: Validation-only surfaces
|
||||
|
||||
Validate whether the current host truly mounts:
|
||||
|
||||
- `sidebar`
|
||||
- `sidebarPanel`
|
||||
- `taskDetailView`
|
||||
- direct-slot `toolbarButton`
|
||||
- direct-slot `contextMenuItem`
|
||||
|
||||
If mounted, add demos.
|
||||
If not mounted, document them as SDK-defined but host-pending.
|
||||
|
||||
## Documentation Deliverables
|
||||
|
||||
The plugin should ship with a README that includes:
|
||||
|
||||
- what it demonstrates
|
||||
- which surfaces are local-only
|
||||
- how to install it
|
||||
- where each UI surface should appear
|
||||
- a mapping from demo card to SDK API
|
||||
|
||||
It should also be referenced from plugin docs as the "reference everything plugin".
|
||||
|
||||
## Testing And Verification
|
||||
|
||||
Minimum verification:
|
||||
|
||||
- package typecheck/build
|
||||
- install from bundled example list
|
||||
- page loads
|
||||
- widget appears
|
||||
- project tab appears
|
||||
- comment surfaces render
|
||||
- settings page loads
|
||||
- key actions succeed
|
||||
|
||||
Recommended manual checklist:
|
||||
|
||||
- create issue from plugin
|
||||
- create goal from plugin
|
||||
- emit and receive plugin event
|
||||
- stream action output
|
||||
- open agent session and receive streamed reply
|
||||
- upload an asset
|
||||
- write plugin activity log
|
||||
- run a safe local process demo
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Should the process demo remain curated-command-only in the first pass?
|
||||
Recommendation: yes.
|
||||
|
||||
2. Should the plugin create throwaway "kitchen sink demo" issues/goals automatically?
|
||||
Recommendation: no. Make creation explicit.
|
||||
|
||||
3. Should we expose unsupported-but-typed surfaces in the UI even if host mounting is not wired?
|
||||
Recommendation: yes, but label them as `SDK-defined / host validation pending`.
|
||||
|
||||
4. Should agent mutation demos include pause/resume by default?
|
||||
Recommendation: probably yes, but behind a warning block.
|
||||
|
||||
5. Should this plugin be treated as a supported regression harness in CI later?
|
||||
Recommendation: yes. Long term, this should be the plugin-runtime smoke test package.
|
||||
|
||||
## Recommended Next Step
|
||||
|
||||
If this plan looks right, the next implementation pass should start by building only:
|
||||
|
||||
- package skeleton
|
||||
- page
|
||||
- settings page
|
||||
- dashboard widget
|
||||
- one project detail tab
|
||||
- one issue detail tab
|
||||
- the basic worker/action/data/state/event scaffolding
|
||||
|
||||
That is enough to lock the architecture before filling in every demo surface.
|
||||
155
doc/plugins/PLUGIN_AUTHORING_GUIDE.md
Normal file
155
doc/plugins/PLUGIN_AUTHORING_GUIDE.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Plugin Authoring Guide
|
||||
|
||||
This guide describes the current, implemented way to create a Paperclip plugin in this repo.
|
||||
|
||||
It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec includes future ideas; this guide only covers the alpha surface that exists now.
|
||||
|
||||
## Current reality
|
||||
|
||||
- Treat plugin workers and plugin UI as trusted code.
|
||||
- Plugin UI runs as same-origin JavaScript inside the main Paperclip app.
|
||||
- Worker-side host APIs are capability-gated.
|
||||
- Plugin UI is not sandboxed by manifest capabilities.
|
||||
- There is no host-provided shared React component kit for plugins yet.
|
||||
- `ctx.assets` is not supported in the current runtime.
|
||||
|
||||
## Scaffold a plugin
|
||||
|
||||
Use the scaffold package:
|
||||
|
||||
```bash
|
||||
pnpm --filter @paperclipai/create-paperclip-plugin build
|
||||
node packages/plugins/create-paperclip-plugin/dist/index.js @yourscope/plugin-name --output ./packages/plugins/examples
|
||||
```
|
||||
|
||||
For a plugin that lives outside the Paperclip repo:
|
||||
|
||||
```bash
|
||||
pnpm --filter @paperclipai/create-paperclip-plugin build
|
||||
node packages/plugins/create-paperclip-plugin/dist/index.js @yourscope/plugin-name \
|
||||
--output /absolute/path/to/plugin-repos \
|
||||
--sdk-path /absolute/path/to/paperclip/packages/plugins/sdk
|
||||
```
|
||||
|
||||
That creates a package with:
|
||||
|
||||
- `src/manifest.ts`
|
||||
- `src/worker.ts`
|
||||
- `src/ui/index.tsx`
|
||||
- `tests/plugin.spec.ts`
|
||||
- `esbuild.config.mjs`
|
||||
- `rollup.config.mjs`
|
||||
|
||||
Inside this monorepo, the scaffold uses `workspace:*` for `@paperclipai/plugin-sdk`.
|
||||
|
||||
Outside this monorepo, the scaffold snapshots `@paperclipai/plugin-sdk` from the local Paperclip checkout into a `.paperclip-sdk/` tarball so you can build and test a plugin without publishing anything to npm first.
|
||||
|
||||
## Recommended local workflow
|
||||
|
||||
From the generated plugin folder:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
For local development, install it into Paperclip from an absolute local path through the plugin manager or API. The server supports local filesystem installs and watches local-path plugins for file changes so worker restarts happen automatically after rebuilds.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:3100/api/plugins/install \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"packageName":"/absolute/path/to/your-plugin","isLocalPath":true}'
|
||||
```
|
||||
|
||||
## Supported alpha surface
|
||||
|
||||
Worker:
|
||||
|
||||
- config
|
||||
- events
|
||||
- jobs
|
||||
- launchers
|
||||
- http
|
||||
- secrets
|
||||
- activity
|
||||
- state
|
||||
- entities
|
||||
- projects and project workspaces
|
||||
- companies
|
||||
- issues and comments
|
||||
- agents and agent sessions
|
||||
- goals
|
||||
- data/actions
|
||||
- streams
|
||||
- tools
|
||||
- metrics
|
||||
- logger
|
||||
|
||||
UI:
|
||||
|
||||
- `usePluginData`
|
||||
- `usePluginAction`
|
||||
- `usePluginStream`
|
||||
- `usePluginToast`
|
||||
- `useHostContext`
|
||||
- typed slot props from `@paperclipai/plugin-sdk/ui`
|
||||
|
||||
Mount surfaces currently wired in the host include:
|
||||
|
||||
- `page`
|
||||
- `settingsPage`
|
||||
- `dashboardWidget`
|
||||
- `sidebar`
|
||||
- `sidebarPanel`
|
||||
- `detailTab`
|
||||
- `taskDetailView`
|
||||
- `projectSidebarItem`
|
||||
- `globalToolbarButton`
|
||||
- `toolbarButton`
|
||||
- `contextMenuItem`
|
||||
- `commentAnnotation`
|
||||
- `commentContextMenuItem`
|
||||
|
||||
## Company routes
|
||||
|
||||
Plugins may declare a `page` slot with `routePath` to own a company route like:
|
||||
|
||||
```text
|
||||
/:companyPrefix/<routePath>
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `routePath` must be a single lowercase slug
|
||||
- it cannot collide with reserved host routes
|
||||
- it cannot duplicate another installed plugin page route
|
||||
|
||||
## Publishing guidance
|
||||
|
||||
- Use npm packages as the deployment artifact.
|
||||
- Treat repo-local example installs as a development workflow only.
|
||||
- Prefer keeping plugin UI self-contained inside the package.
|
||||
- Do not rely on host design-system components or undocumented app internals.
|
||||
- GitHub repository installs are not a first-class workflow today. For local development, use a checked-out local path. For production, publish to npm or a private npm-compatible registry.
|
||||
|
||||
## Verification before handoff
|
||||
|
||||
At minimum:
|
||||
|
||||
```bash
|
||||
pnpm --filter <your-plugin-package> typecheck
|
||||
pnpm --filter <your-plugin-package> test
|
||||
pnpm --filter <your-plugin-package> build
|
||||
```
|
||||
|
||||
If you changed host integration too, also run:
|
||||
|
||||
```bash
|
||||
pnpm -r typecheck
|
||||
pnpm test:run
|
||||
pnpm build
|
||||
```
|
||||
@@ -8,6 +8,29 @@ It expands the brief plugin notes in [doc/SPEC.md](../SPEC.md) and should be rea
|
||||
This is not part of the V1 implementation contract in [doc/SPEC-implementation.md](../SPEC-implementation.md).
|
||||
It is the full target architecture for the plugin system that should follow V1.
|
||||
|
||||
## Current implementation caveats
|
||||
|
||||
The code in this repo now includes an early plugin runtime and admin UI, but it does not yet deliver the full deployment model described in this spec.
|
||||
|
||||
Today, the practical deployment model is:
|
||||
|
||||
- single-tenant
|
||||
- self-hosted
|
||||
- single-node or otherwise filesystem-persistent
|
||||
|
||||
Current limitations to keep in mind:
|
||||
|
||||
- Plugin UI bundles currently run as same-origin JavaScript inside the main Paperclip app. Treat plugin UI as trusted code, not a sandboxed frontend capability boundary.
|
||||
- Manifest capabilities currently gate worker-side host RPC calls. They do not prevent plugin UI code from calling ordinary Paperclip HTTP APIs directly.
|
||||
- Runtime installs assume a writable local filesystem for the plugin package directory and plugin data directory.
|
||||
- Runtime npm installs assume `npm` is available in the running environment and that the host can reach the configured package registry.
|
||||
- Published npm packages are the intended install artifact for deployed plugins.
|
||||
- The repo example plugins under `packages/plugins/examples/` are development conveniences. They work from a source checkout and should not be assumed to exist in a generic published build unless they are explicitly shipped with that build.
|
||||
- Dynamic plugin install is not yet cloud-ready for horizontally scaled or ephemeral deployments. There is no shared artifact store, install coordination, or cross-node distribution layer yet.
|
||||
- The current runtime does not yet ship a real host-provided plugin UI component kit, and it does not support plugin asset uploads/reads. Treat those as future-scope ideas in this spec, not current implementation promises.
|
||||
|
||||
In practice, that means the current implementation is a good fit for local development and self-hosted persistent deployments, but not yet for multi-instance cloud plugin distribution.
|
||||
|
||||
## 1. Scope
|
||||
|
||||
This spec covers:
|
||||
@@ -212,6 +235,8 @@ Suggested layout:
|
||||
|
||||
The package install directory and the plugin data directory are separate.
|
||||
|
||||
This on-disk model is the reason the current implementation expects a persistent writable host filesystem. Cloud-safe artifact replication is future work.
|
||||
|
||||
## 8.2 Operator Commands
|
||||
|
||||
Paperclip should add CLI commands:
|
||||
@@ -237,6 +262,8 @@ The install process is:
|
||||
7. Start plugin worker and run health/validation.
|
||||
8. Mark plugin `ready` or `error`.
|
||||
|
||||
For the current implementation, this install flow should be read as a single-host workflow. A successful install writes packages to the local host, and other app nodes will not automatically receive that plugin unless a future shared distribution mechanism is added.
|
||||
|
||||
## 9. Load Order And Precedence
|
||||
|
||||
Load order must be deterministic.
|
||||
|
||||
33
docker-compose.untrusted-review.yml
Normal file
33
docker-compose.untrusted-review.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
services:
|
||||
review:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/untrusted-review/Dockerfile
|
||||
init: true
|
||||
tty: true
|
||||
stdin_open: true
|
||||
working_dir: /work
|
||||
environment:
|
||||
HOME: "/home/reviewer"
|
||||
CODEX_HOME: "/home/reviewer/.codex"
|
||||
CLAUDE_HOME: "/home/reviewer/.claude"
|
||||
PAPERCLIP_HOME: "/home/reviewer/.paperclip-review"
|
||||
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
|
||||
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
|
||||
GITHUB_TOKEN: "${GITHUB_TOKEN:-}"
|
||||
ports:
|
||||
- "${REVIEW_PAPERCLIP_PORT:-3100}:3100"
|
||||
- "${REVIEW_VITE_PORT:-5173}:5173"
|
||||
volumes:
|
||||
- review-home:/home/reviewer
|
||||
- review-work:/work
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
tmpfs:
|
||||
- /tmp:mode=1777,size=1g
|
||||
|
||||
volumes:
|
||||
review-home:
|
||||
review-work:
|
||||
44
docker/untrusted-review/Dockerfile
Normal file
44
docker/untrusted-review/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
||||
FROM node:lts-trixie-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
curl \
|
||||
fd-find \
|
||||
gh \
|
||||
git \
|
||||
jq \
|
||||
less \
|
||||
openssh-client \
|
||||
procps \
|
||||
ripgrep \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN ln -sf /usr/bin/fdfind /usr/local/bin/fd
|
||||
|
||||
RUN corepack enable \
|
||||
&& npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest
|
||||
|
||||
RUN useradd --create-home --shell /bin/bash reviewer
|
||||
|
||||
ENV HOME=/home/reviewer \
|
||||
CODEX_HOME=/home/reviewer/.codex \
|
||||
CLAUDE_HOME=/home/reviewer/.claude \
|
||||
PAPERCLIP_HOME=/home/reviewer/.paperclip-review \
|
||||
PNPM_HOME=/home/reviewer/.local/share/pnpm \
|
||||
PATH=/home/reviewer/.local/share/pnpm:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
WORKDIR /work
|
||||
|
||||
COPY --chown=reviewer:reviewer docker/untrusted-review/bin/review-checkout-pr /usr/local/bin/review-checkout-pr
|
||||
|
||||
RUN chmod +x /usr/local/bin/review-checkout-pr \
|
||||
&& mkdir -p /work \
|
||||
&& chown -R reviewer:reviewer /work
|
||||
|
||||
USER reviewer
|
||||
|
||||
EXPOSE 3100 5173
|
||||
|
||||
CMD ["bash", "-l"]
|
||||
65
docker/untrusted-review/bin/review-checkout-pr
Normal file
65
docker/untrusted-review/bin/review-checkout-pr
Normal file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: review-checkout-pr <owner/repo|github-url> <pr-number> [checkout-dir]
|
||||
|
||||
Examples:
|
||||
review-checkout-pr paperclipai/paperclip 432
|
||||
review-checkout-pr https://github.com/paperclipai/paperclip.git 432
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ $# -lt 2 || $# -gt 3 ]]; then
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
normalize_repo_slug() {
|
||||
local raw="$1"
|
||||
raw="${raw#git@github.com:}"
|
||||
raw="${raw#ssh://git@github.com/}"
|
||||
raw="${raw#https://github.com/}"
|
||||
raw="${raw#http://github.com/}"
|
||||
raw="${raw%.git}"
|
||||
printf '%s\n' "${raw#/}"
|
||||
}
|
||||
|
||||
repo_slug="$(normalize_repo_slug "$1")"
|
||||
pr_number="$2"
|
||||
|
||||
if [[ ! "$repo_slug" =~ ^[^/]+/[^/]+$ ]]; then
|
||||
echo "Expected GitHub repo slug like owner/repo or a GitHub repo URL, got: $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! "$pr_number" =~ ^[0-9]+$ ]]; then
|
||||
echo "PR number must be numeric, got: $pr_number" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
repo_key="${repo_slug//\//-}"
|
||||
mirror_dir="/work/repos/${repo_key}"
|
||||
checkout_dir="${3:-/work/checkouts/${repo_key}/pr-${pr_number}}"
|
||||
pr_ref="refs/remotes/origin/pr/${pr_number}"
|
||||
|
||||
mkdir -p "$(dirname "$mirror_dir")" "$(dirname "$checkout_dir")"
|
||||
|
||||
if [[ ! -d "$mirror_dir/.git" ]]; then
|
||||
if command -v gh >/dev/null 2>&1; then
|
||||
gh repo clone "$repo_slug" "$mirror_dir" -- --filter=blob:none
|
||||
else
|
||||
git clone --filter=blob:none "https://github.com/${repo_slug}.git" "$mirror_dir"
|
||||
fi
|
||||
fi
|
||||
|
||||
git -C "$mirror_dir" fetch --force origin "pull/${pr_number}/head:${pr_ref}"
|
||||
|
||||
if [[ -e "$checkout_dir" ]]; then
|
||||
printf '%s\n' "$checkout_dir"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git -C "$mirror_dir" worktree add --detach "$checkout_dir" "$pr_ref" >/dev/null
|
||||
printf '%s\n' "$checkout_dir"
|
||||
@@ -30,6 +30,8 @@ Codex uses `previous_response_id` for session continuity. The adapter serializes
|
||||
|
||||
The adapter symlinks Paperclip skills into the global Codex skills directory (`~/.codex/skills`). Existing user skills are not overwritten.
|
||||
|
||||
When Paperclip is running inside a managed worktree instance (`PAPERCLIP_IN_WORKTREE=true`), the adapter instead uses a worktree-isolated `CODEX_HOME` under the Paperclip instance so Codex skills, sessions, logs, and other runtime state do not leak across checkouts. It seeds that isolated home from the user's main Codex home for shared auth/config continuity.
|
||||
|
||||
For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -6,7 +6,7 @@ summary: Guide to building a custom adapter
|
||||
Build a custom adapter to connect Paperclip to any agent runtime.
|
||||
|
||||
<Tip>
|
||||
If you're using Claude Code, the `create-agent-adapter` skill can guide you through the full adapter creation process interactively. Just ask Claude to create a new adapter and it will walk you through each step.
|
||||
If you're using Claude Code, the `.agents/skills/create-agent-adapter` skill can guide you through the full adapter creation process interactively. Just ask Claude to create a new adapter and it will walk you through each step.
|
||||
</Tip>
|
||||
|
||||
## Package Structure
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
title: Issues
|
||||
summary: Issue CRUD, checkout/release, comments, and attachments
|
||||
summary: Issue CRUD, checkout/release, comments, documents, and attachments
|
||||
---
|
||||
|
||||
Issues are the unit of work in Paperclip. They support hierarchical relationships, atomic checkout, comments, and file attachments.
|
||||
Issues are the unit of work in Paperclip. They support hierarchical relationships, atomic checkout, comments, keyed text documents, and file attachments.
|
||||
|
||||
## List Issues
|
||||
|
||||
@@ -29,6 +29,12 @@ GET /api/issues/{issueId}
|
||||
|
||||
Returns the issue with `project`, `goal`, and `ancestors` (parent chain with their projects and goals).
|
||||
|
||||
The response also includes:
|
||||
|
||||
- `planDocument`: the full text of the issue document with key `plan`, when present
|
||||
- `documentSummaries`: metadata for all linked issue documents
|
||||
- `legacyPlanDocument`: a read-only fallback when the description still contains an old `<plan>` block
|
||||
|
||||
## Create Issue
|
||||
|
||||
```
|
||||
@@ -100,6 +106,54 @@ POST /api/issues/{issueId}/comments
|
||||
|
||||
@-mentions (`@AgentName`) in comments trigger heartbeats for the mentioned agent.
|
||||
|
||||
## Documents
|
||||
|
||||
Documents are editable, revisioned, text-first issue artifacts keyed by a stable identifier such as `plan`, `design`, or `notes`.
|
||||
|
||||
### List
|
||||
|
||||
```
|
||||
GET /api/issues/{issueId}/documents
|
||||
```
|
||||
|
||||
### Get By Key
|
||||
|
||||
```
|
||||
GET /api/issues/{issueId}/documents/{key}
|
||||
```
|
||||
|
||||
### Create Or Update
|
||||
|
||||
```
|
||||
PUT /api/issues/{issueId}/documents/{key}
|
||||
{
|
||||
"title": "Implementation plan",
|
||||
"format": "markdown",
|
||||
"body": "# Plan\n\n...",
|
||||
"baseRevisionId": "{latestRevisionId}"
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- omit `baseRevisionId` when creating a new document
|
||||
- provide the current `baseRevisionId` when updating an existing document
|
||||
- stale `baseRevisionId` returns `409 Conflict`
|
||||
|
||||
### Revision History
|
||||
|
||||
```
|
||||
GET /api/issues/{issueId}/documents/{key}/revisions
|
||||
```
|
||||
|
||||
### Delete
|
||||
|
||||
```
|
||||
DELETE /api/issues/{issueId}/documents/{key}
|
||||
```
|
||||
|
||||
Delete is board-only in the current implementation.
|
||||
|
||||
## Attachments
|
||||
|
||||
### Upload
|
||||
|
||||
569
docs/plans/2026-03-13-issue-documents-plan.md
Normal file
569
docs/plans/2026-03-13-issue-documents-plan.md
Normal file
@@ -0,0 +1,569 @@
|
||||
# Issue Documents Plan
|
||||
|
||||
Status: Draft
|
||||
Owner: Backend + UI + Agent Protocol
|
||||
Date: 2026-03-13
|
||||
Primary issue: `PAP-448`
|
||||
|
||||
## Summary
|
||||
|
||||
Add first-class **documents** to Paperclip as editable, revisioned, company-scoped text artifacts that can be linked to issues.
|
||||
|
||||
The first required convention is a document with key `plan`.
|
||||
|
||||
This solves the immediate workflow problem in `PAP-448`:
|
||||
|
||||
- plans should stop living inside issue descriptions as `<plan>` blocks
|
||||
- agents and board users should be able to create/update issue documents directly
|
||||
- `GET /api/issues/:id` should include the full `plan` document and expose the other available documents
|
||||
- issue detail should render documents under the description
|
||||
|
||||
This should be built as the **text-document slice** of the broader artifact system, not as a replacement for attachments/assets.
|
||||
|
||||
## Recommended Product Shape
|
||||
|
||||
### Documents vs attachments vs artifacts
|
||||
|
||||
- **Documents**: editable text content with stable keys and revision history.
|
||||
- **Attachments**: uploaded/generated opaque files backed by storage (`assets` + `issue_attachments`).
|
||||
- **Artifacts**: later umbrella/read-model that can unify documents, attachments, previews, and workspace files.
|
||||
|
||||
Recommendation:
|
||||
|
||||
- implement **issue documents now**
|
||||
- keep existing attachments as-is
|
||||
- defer full artifact unification until there is a second real consumer beyond issue documents + attachments
|
||||
|
||||
This keeps `PAP-448` focused while still fitting the larger artifact direction.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Give issues first-class keyed documents, starting with `plan`.
|
||||
2. Make documents editable by board users and same-company agents with issue access.
|
||||
3. Preserve change history with append-only revisions.
|
||||
4. Make the `plan` document automatically available in the normal issue fetch used by agents/heartbeats.
|
||||
5. Replace the current `<plan>`-in-description convention in skills/docs.
|
||||
6. Keep the design compatible with a future artifact/deliverables layer.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- full collaborative doc editing
|
||||
- binary-file version history
|
||||
- browser IDE or workspace editor
|
||||
- full artifact-system implementation in the same change
|
||||
- generalized polymorphic relations for every entity type on day one
|
||||
|
||||
## Product Decisions
|
||||
|
||||
### 1. Keyed issue documents
|
||||
|
||||
Each issue can have multiple documents. Each document relation has a stable key:
|
||||
|
||||
- `plan`
|
||||
- `design`
|
||||
- `notes`
|
||||
- `report`
|
||||
- custom keys later
|
||||
|
||||
Key rules:
|
||||
|
||||
- unique per issue, case-insensitive
|
||||
- normalized to lowercase slug form
|
||||
- machine-oriented and stable
|
||||
- title is separate and user-facing
|
||||
|
||||
The `plan` key is conventional and reserved by Paperclip workflow/docs.
|
||||
|
||||
### 2. Text-first v1
|
||||
|
||||
V1 documents should be text-first, not arbitrary blobs.
|
||||
|
||||
Recommended supported formats:
|
||||
|
||||
- `markdown`
|
||||
- `plain_text`
|
||||
- `json`
|
||||
- `html`
|
||||
|
||||
Recommendation:
|
||||
|
||||
- optimize UI for `markdown`
|
||||
- allow raw editing for the others
|
||||
- keep PDFs/images/CSVs/etc as attachments/artifacts, not editable documents
|
||||
|
||||
### 3. Revision model
|
||||
|
||||
Every document update creates a new immutable revision.
|
||||
|
||||
The current document row stores the latest snapshot for fast reads.
|
||||
|
||||
### 4. Concurrency model
|
||||
|
||||
Do not use silent last-write-wins.
|
||||
|
||||
Updates should include `baseRevisionId`:
|
||||
|
||||
- create: no base revision required
|
||||
- update: `baseRevisionId` must match current latest revision
|
||||
- mismatch: return `409 Conflict`
|
||||
|
||||
This is important because both board users and agents may edit the same document.
|
||||
|
||||
### 5. Issue fetch behavior
|
||||
|
||||
`GET /api/issues/:id` should include:
|
||||
|
||||
- full `planDocument` when a `plan` document exists
|
||||
- `documentSummaries` for all linked documents
|
||||
|
||||
It should not inline every document body by default.
|
||||
|
||||
This keeps issue fetches useful for agents without making every issue payload unbounded.
|
||||
|
||||
### 6. Legacy `<plan>` compatibility
|
||||
|
||||
If an issue has no `plan` document but its description contains a legacy `<plan>` block:
|
||||
|
||||
- expose that as a legacy read-only fallback in API/UI
|
||||
- mark it as legacy/synthetic
|
||||
- prefer a real `plan` document when both exist
|
||||
|
||||
Recommendation:
|
||||
|
||||
- do not auto-rewrite old issue descriptions in the first rollout
|
||||
- provide an explicit import/migrate path later
|
||||
|
||||
## Proposed Data Model
|
||||
|
||||
Recommendation: make documents first-class, but keep issue linkage explicit via a join table.
|
||||
|
||||
This preserves foreign keys today and gives a clean path to future `project_documents` or `company_documents` tables later.
|
||||
|
||||
## Tables
|
||||
|
||||
### `documents`
|
||||
|
||||
Canonical text document record.
|
||||
|
||||
Suggested columns:
|
||||
|
||||
- `id`
|
||||
- `company_id`
|
||||
- `title`
|
||||
- `format`
|
||||
- `latest_body`
|
||||
- `latest_revision_id`
|
||||
- `latest_revision_number`
|
||||
- `created_by_agent_id`
|
||||
- `created_by_user_id`
|
||||
- `updated_by_agent_id`
|
||||
- `updated_by_user_id`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
### `document_revisions`
|
||||
|
||||
Append-only history.
|
||||
|
||||
Suggested columns:
|
||||
|
||||
- `id`
|
||||
- `company_id`
|
||||
- `document_id`
|
||||
- `revision_number`
|
||||
- `body`
|
||||
- `change_summary`
|
||||
- `created_by_agent_id`
|
||||
- `created_by_user_id`
|
||||
- `created_at`
|
||||
|
||||
Constraints:
|
||||
|
||||
- unique `(document_id, revision_number)`
|
||||
|
||||
### `issue_documents`
|
||||
|
||||
Issue relation + workflow key.
|
||||
|
||||
Suggested columns:
|
||||
|
||||
- `id`
|
||||
- `company_id`
|
||||
- `issue_id`
|
||||
- `document_id`
|
||||
- `key`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
Constraints:
|
||||
|
||||
- unique `(company_id, issue_id, key)`
|
||||
- unique `(document_id)` to keep one issue relation per document in v1
|
||||
|
||||
## Why not use `assets` for this?
|
||||
|
||||
Because `assets` solves blob storage, not:
|
||||
|
||||
- stable keyed semantics like `plan`
|
||||
- inline text editing
|
||||
- revision history
|
||||
- optimistic concurrency
|
||||
- cheap inclusion in `GET /issues/:id`
|
||||
|
||||
Documents and attachments should remain separate primitives, then meet later in a deliverables/artifact read-model.
|
||||
|
||||
## Shared Types and API Contract
|
||||
|
||||
## New shared types
|
||||
|
||||
Add:
|
||||
|
||||
- `DocumentFormat`
|
||||
- `IssueDocument`
|
||||
- `IssueDocumentSummary`
|
||||
- `DocumentRevision`
|
||||
|
||||
Recommended `IssueDocument` shape:
|
||||
|
||||
```ts
|
||||
type DocumentFormat = "markdown" | "plain_text" | "json" | "html";
|
||||
|
||||
interface IssueDocument {
|
||||
id: string;
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
key: string;
|
||||
title: string | null;
|
||||
format: DocumentFormat;
|
||||
body: string;
|
||||
latestRevisionId: string;
|
||||
latestRevisionNumber: number;
|
||||
createdByAgentId: string | null;
|
||||
createdByUserId: string | null;
|
||||
updatedByAgentId: string | null;
|
||||
updatedByUserId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
Recommended `IssueDocumentSummary` shape:
|
||||
|
||||
```ts
|
||||
interface IssueDocumentSummary {
|
||||
id: string;
|
||||
key: string;
|
||||
title: string | null;
|
||||
format: DocumentFormat;
|
||||
latestRevisionId: string;
|
||||
latestRevisionNumber: number;
|
||||
updatedAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
## Issue type enrichment
|
||||
|
||||
Extend `Issue` with:
|
||||
|
||||
```ts
|
||||
interface Issue {
|
||||
...
|
||||
planDocument?: IssueDocument | null;
|
||||
documentSummaries?: IssueDocumentSummary[];
|
||||
legacyPlanDocument?: {
|
||||
key: "plan";
|
||||
body: string;
|
||||
source: "issue_description";
|
||||
} | null;
|
||||
}
|
||||
```
|
||||
|
||||
This directly satisfies the `PAP-448` requirement for heartbeat/API issue fetches.
|
||||
|
||||
## API endpoints
|
||||
|
||||
Recommended endpoints:
|
||||
|
||||
- `GET /api/issues/:issueId/documents`
|
||||
- `GET /api/issues/:issueId/documents/:key`
|
||||
- `PUT /api/issues/:issueId/documents/:key`
|
||||
- `GET /api/issues/:issueId/documents/:key/revisions`
|
||||
- `DELETE /api/issues/:issueId/documents/:key` optionally board-only in v1
|
||||
|
||||
Recommended `PUT` body:
|
||||
|
||||
```ts
|
||||
{
|
||||
title?: string | null;
|
||||
format: "markdown" | "plain_text" | "json" | "html";
|
||||
body: string;
|
||||
changeSummary?: string | null;
|
||||
baseRevisionId?: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- missing document + no `baseRevisionId`: create
|
||||
- existing document + matching `baseRevisionId`: update
|
||||
- existing document + stale `baseRevisionId`: `409`
|
||||
|
||||
## Authorization and invariants
|
||||
|
||||
- all document records are company-scoped
|
||||
- issue relation must belong to same company
|
||||
- board access follows existing issue access rules
|
||||
- agent access follows existing same-company issue access rules
|
||||
- every mutation writes activity log entries
|
||||
|
||||
Recommended delete rule for v1:
|
||||
|
||||
- board can delete documents
|
||||
- agents can create/update, but not delete
|
||||
|
||||
That keeps automated systems from removing canonical docs too easily.
|
||||
|
||||
## UI Plan
|
||||
|
||||
## Issue detail
|
||||
|
||||
Add a new **Documents** section directly under the issue description.
|
||||
|
||||
Recommended behavior:
|
||||
|
||||
- show `plan` first when present
|
||||
- show other documents below it
|
||||
- render a gist-like header:
|
||||
- key
|
||||
- title
|
||||
- last updated metadata
|
||||
- revision number
|
||||
- support inline edit
|
||||
- support create new document by key
|
||||
- support revision history drawer or sheet
|
||||
|
||||
Recommended presentation order:
|
||||
|
||||
1. Description
|
||||
2. Documents
|
||||
3. Attachments
|
||||
4. Comments / activity / sub-issues
|
||||
|
||||
This matches the request that documents live under the description while still leaving attachments available.
|
||||
|
||||
## Editing UX
|
||||
|
||||
Recommendation:
|
||||
|
||||
- use markdown preview + raw edit toggle for markdown docs
|
||||
- use raw textarea editor for non-markdown docs in v1
|
||||
- show explicit save conflicts on `409`
|
||||
- show a clear empty state: "No documents yet"
|
||||
|
||||
## Legacy plan rendering
|
||||
|
||||
If there is no stored `plan` document but legacy `<plan>` exists:
|
||||
|
||||
- show it in the Documents section
|
||||
- mark it `Legacy plan from description`
|
||||
- offer create/import in a later pass
|
||||
|
||||
## Agent Protocol and Skills
|
||||
|
||||
Update the Paperclip agent workflow so planning no longer edits the issue description.
|
||||
|
||||
Required changes:
|
||||
|
||||
- update `skills/paperclip/SKILL.md`
|
||||
- replace the `<plan>` instructions with document creation/update instructions
|
||||
- document the new endpoints in `docs/api/issues.md`
|
||||
- update any internal planning docs that still teach inline `<plan>` blocks
|
||||
|
||||
New rule:
|
||||
|
||||
- when asked to make a plan for an issue, create or update the issue document with key `plan`
|
||||
- leave a comment that the plan document was created/updated
|
||||
- do not mark the issue done
|
||||
|
||||
## Relationship to the Artifact Plan
|
||||
|
||||
This work should explicitly feed the broader artifact/deliverables direction.
|
||||
|
||||
Recommendation:
|
||||
|
||||
- keep documents as their own primitive in this change
|
||||
- add `document` to any future `ArtifactKind`
|
||||
- later build a deliverables read-model that aggregates:
|
||||
- issue documents
|
||||
- issue attachments
|
||||
- preview URLs
|
||||
- workspace-file references
|
||||
|
||||
The artifact proposal currently has no explicit `document` kind. It should.
|
||||
|
||||
Recommended future shape:
|
||||
|
||||
```ts
|
||||
type ArtifactKind =
|
||||
| "document"
|
||||
| "attachment"
|
||||
| "workspace_file"
|
||||
| "preview"
|
||||
| "report_link";
|
||||
```
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
## Phase 1: Shared contract and schema
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/db/src/schema/documents.ts`
|
||||
- `packages/db/src/schema/document_revisions.ts`
|
||||
- `packages/db/src/schema/issue_documents.ts`
|
||||
- `packages/db/src/schema/index.ts`
|
||||
- `packages/db/src/migrations/*`
|
||||
- `packages/shared/src/types/issue.ts`
|
||||
- `packages/shared/src/validators/issue.ts` or new document validator file
|
||||
- `packages/shared/src/index.ts`
|
||||
|
||||
Acceptance:
|
||||
|
||||
- schema enforces one key per issue
|
||||
- revisions are append-only
|
||||
- shared types expose plan/document fields on issue fetch
|
||||
|
||||
## Phase 2: Server services and routes
|
||||
|
||||
Files:
|
||||
|
||||
- `server/src/services/issues.ts` or `server/src/services/documents.ts`
|
||||
- `server/src/routes/issues.ts`
|
||||
- `server/src/services/activity.ts` callsites
|
||||
|
||||
Behavior:
|
||||
|
||||
- list/get/upsert/delete documents
|
||||
- revision listing
|
||||
- `GET /issues/:id` returns `planDocument` + `documentSummaries`
|
||||
- company boundary checks match issue routes
|
||||
|
||||
Acceptance:
|
||||
|
||||
- agents and board can fetch/update same-company issue documents
|
||||
- stale edits return `409`
|
||||
- activity timeline shows document changes
|
||||
|
||||
## Phase 3: UI issue documents surface
|
||||
|
||||
Files:
|
||||
|
||||
- `ui/src/api/issues.ts`
|
||||
- `ui/src/lib/queryKeys.ts`
|
||||
- `ui/src/pages/IssueDetail.tsx`
|
||||
- new reusable document UI component if needed
|
||||
|
||||
Behavior:
|
||||
|
||||
- render plan + documents under description
|
||||
- create/update by key
|
||||
- open revision history
|
||||
- show conflicts/errors clearly
|
||||
|
||||
Acceptance:
|
||||
|
||||
- board can create a `plan` doc from issue detail
|
||||
- updated plan appears immediately
|
||||
- issue detail no longer depends on description-embedded `<plan>`
|
||||
|
||||
## Phase 4: Skills/docs migration
|
||||
|
||||
Files:
|
||||
|
||||
- `skills/paperclip/SKILL.md`
|
||||
- `docs/api/issues.md`
|
||||
- `doc/SPEC-implementation.md`
|
||||
- relevant plan/docs that mention `<plan>`
|
||||
|
||||
Acceptance:
|
||||
|
||||
- planning guidance references issue documents, not inline issue description tags
|
||||
- API docs describe the new document endpoints and issue payload additions
|
||||
|
||||
## Phase 5: Legacy compatibility and follow-up
|
||||
|
||||
Behavior:
|
||||
|
||||
- read legacy `<plan>` blocks as fallback
|
||||
- optionally add explicit import/migration command later
|
||||
|
||||
Follow-up, not required for first merge:
|
||||
|
||||
- deliverables/artifact read-model
|
||||
- project/company documents
|
||||
- comment-linked documents
|
||||
- diff view between revisions
|
||||
|
||||
## Test Plan
|
||||
|
||||
### Server
|
||||
|
||||
- document create/read/update/delete lifecycle
|
||||
- revision numbering
|
||||
- `baseRevisionId` conflict handling
|
||||
- company boundary enforcement
|
||||
- agent vs board authorization
|
||||
- issue fetch includes `planDocument` and document summaries
|
||||
- legacy `<plan>` fallback behavior
|
||||
- activity log mutation coverage
|
||||
|
||||
### UI
|
||||
|
||||
- issue detail shows plan document
|
||||
- create/update flows invalidate queries correctly
|
||||
- conflict and validation errors are surfaced
|
||||
- legacy plan fallback renders correctly
|
||||
|
||||
### Verification
|
||||
|
||||
Run before implementation is declared complete:
|
||||
|
||||
```sh
|
||||
pnpm -r typecheck
|
||||
pnpm test:run
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Should v1 documents be markdown-only, with `json/html/plain_text` deferred?
|
||||
Recommendation: allow all four in API, optimize UI for markdown only.
|
||||
|
||||
2. Should agents be allowed to create arbitrary keys, or only conventional keys?
|
||||
Recommendation: allow arbitrary keys with normalized validation; reserve `plan` as special behavior only.
|
||||
|
||||
3. Should delete exist in v1?
|
||||
Recommendation: yes, but board-only.
|
||||
|
||||
4. Should legacy `<plan>` blocks ever be auto-migrated?
|
||||
Recommendation: no automatic mutation in the first rollout.
|
||||
|
||||
5. Should documents appear inside a future Deliverables section or remain a top-level Issue section?
|
||||
Recommendation: keep a dedicated Documents section now; later also expose them in Deliverables if an aggregated artifact view is added.
|
||||
|
||||
## Final Recommendation
|
||||
|
||||
Ship **issue documents** as a focused, text-first primitive now.
|
||||
|
||||
Do not try to solve full artifact unification in the same implementation.
|
||||
|
||||
Use:
|
||||
|
||||
- first-class document tables
|
||||
- issue-level keyed linkage
|
||||
- append-only revisions
|
||||
- `planDocument` embedded in normal issue fetches
|
||||
- legacy `<plan>` fallback
|
||||
- skill/docs migration away from description-embedded plans
|
||||
|
||||
This addresses the real planning workflow problem immediately and leaves the artifact system room to grow cleanly afterward.
|
||||
@@ -1,5 +1,11 @@
|
||||
# @paperclipai/adapter-utils
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stable release preparation for 0.3.1
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-utils",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -112,6 +112,16 @@ export function renderTemplate(template: string, data: Record<string, unknown>)
|
||||
return template.replace(/{{\s*([a-zA-Z0-9_.-]+)\s*}}/g, (_, path) => resolvePathValue(data, path));
|
||||
}
|
||||
|
||||
export function joinPromptSections(
|
||||
sections: Array<string | null | undefined>,
|
||||
separator = "\n\n",
|
||||
) {
|
||||
return sections
|
||||
.map((value) => (typeof value === "string" ? value.trim() : ""))
|
||||
.filter(Boolean)
|
||||
.join(separator);
|
||||
}
|
||||
|
||||
export function redactEnvForLogs(env: Record<string, string>): Record<string, string> {
|
||||
const redacted: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
|
||||
@@ -99,6 +99,7 @@ export interface AdapterInvocationMeta {
|
||||
commandNotes?: string[];
|
||||
env?: Record<string, string>;
|
||||
prompt?: string;
|
||||
promptMetrics?: Record<string, number>;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @paperclipai/adapter-claude-local
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stable release preparation for 0.3.1
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.1
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-claude-local",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
parseObject,
|
||||
parseJson,
|
||||
buildPaperclipEnv,
|
||||
joinPromptSections,
|
||||
redactEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
@@ -121,6 +122,7 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "") || null;
|
||||
const workspaceBranch = asString(workspaceContext.branchName, "") || null;
|
||||
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "") || null;
|
||||
const agentHome = asString(workspaceContext.agentHome, "") || null;
|
||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
@@ -215,6 +217,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
if (workspaceWorktreePath) {
|
||||
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
|
||||
}
|
||||
if (agentHome) {
|
||||
env.AGENT_HOME = agentHome;
|
||||
}
|
||||
if (workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
}
|
||||
@@ -363,7 +368,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
`[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||
);
|
||||
}
|
||||
const prompt = renderTemplate(promptTemplate, {
|
||||
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
||||
const templateData = {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId,
|
||||
@@ -371,7 +377,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
agent,
|
||||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
});
|
||||
};
|
||||
const renderedPrompt = renderTemplate(promptTemplate, templateData);
|
||||
const renderedBootstrapPrompt =
|
||||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||
const prompt = joinPromptSections([
|
||||
renderedBootstrapPrompt,
|
||||
sessionHandoffNote,
|
||||
renderedPrompt,
|
||||
]);
|
||||
const promptMetrics = {
|
||||
promptChars: prompt.length,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
};
|
||||
|
||||
const buildClaudeArgs = (resumeSessionId: string | null) => {
|
||||
const args = ["--print", "-", "--output-format", "stream-json", "--verbose"];
|
||||
@@ -416,6 +439,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
commandNotes,
|
||||
env: redactEnvForLogs(env),
|
||||
prompt,
|
||||
promptMetrics,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, un
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||
if (v.model) ac.model = v.model;
|
||||
if (v.thinkingEffort) ac.effort = v.thinkingEffort;
|
||||
if (v.chrome) ac.chrome = true;
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @paperclipai/adapter-codex-local
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stable release preparation for 0.3.1
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.1
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-codex-local",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
101
packages/adapters/codex-local/src/server/codex-home.ts
Normal file
101
packages/adapters/codex-local/src/server/codex-home.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||
|
||||
const TRUTHY_ENV_RE = /^(1|true|yes|on)$/i;
|
||||
const COPIED_SHARED_FILES = ["config.json", "config.toml", "instructions.md"] as const;
|
||||
const SYMLINKED_SHARED_FILES = ["auth.json"] as const;
|
||||
|
||||
function nonEmpty(value: string | undefined): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
export async function pathExists(candidate: string): Promise<boolean> {
|
||||
return fs.access(candidate).then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
export function resolveCodexHomeDir(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const fromEnv = nonEmpty(env.CODEX_HOME);
|
||||
if (fromEnv) return path.resolve(fromEnv);
|
||||
return path.join(os.homedir(), ".codex");
|
||||
}
|
||||
|
||||
function isWorktreeMode(env: NodeJS.ProcessEnv): boolean {
|
||||
return TRUTHY_ENV_RE.test(env.PAPERCLIP_IN_WORKTREE ?? "");
|
||||
}
|
||||
|
||||
function resolveWorktreeCodexHomeDir(env: NodeJS.ProcessEnv): string | null {
|
||||
if (!isWorktreeMode(env)) return null;
|
||||
const paperclipHome = nonEmpty(env.PAPERCLIP_HOME);
|
||||
if (!paperclipHome) return null;
|
||||
const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID);
|
||||
if (instanceId) {
|
||||
return path.resolve(paperclipHome, "instances", instanceId, "codex-home");
|
||||
}
|
||||
return path.resolve(paperclipHome, "codex-home");
|
||||
}
|
||||
|
||||
async function ensureParentDir(target: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
}
|
||||
|
||||
async function ensureSymlink(target: string, source: string): Promise<void> {
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (!existing) {
|
||||
await ensureParentDir(target);
|
||||
await fs.symlink(source, target);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existing.isSymbolicLink()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const linkedPath = await fs.readlink(target).catch(() => null);
|
||||
if (!linkedPath) return;
|
||||
|
||||
const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);
|
||||
if (resolvedLinkedPath === source) return;
|
||||
|
||||
await fs.unlink(target);
|
||||
await fs.symlink(source, target);
|
||||
}
|
||||
|
||||
async function ensureCopiedFile(target: string, source: string): Promise<void> {
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (existing) return;
|
||||
await ensureParentDir(target);
|
||||
await fs.copyFile(source, target);
|
||||
}
|
||||
|
||||
export async function prepareWorktreeCodexHome(
|
||||
env: NodeJS.ProcessEnv,
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
): Promise<string | null> {
|
||||
const targetHome = resolveWorktreeCodexHomeDir(env);
|
||||
if (!targetHome) return null;
|
||||
|
||||
const sourceHome = resolveCodexHomeDir(env);
|
||||
if (path.resolve(sourceHome) === path.resolve(targetHome)) return targetHome;
|
||||
|
||||
await fs.mkdir(targetHome, { recursive: true });
|
||||
|
||||
for (const name of SYMLINKED_SHARED_FILES) {
|
||||
const source = path.join(sourceHome, name);
|
||||
if (!(await pathExists(source))) continue;
|
||||
await ensureSymlink(path.join(targetHome, name), source);
|
||||
}
|
||||
|
||||
for (const name of COPIED_SHARED_FILES) {
|
||||
const source = path.join(sourceHome, name);
|
||||
if (!(await pathExists(source))) continue;
|
||||
await ensureCopiedFile(path.join(targetHome, name), source);
|
||||
}
|
||||
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Using worktree-isolated Codex home "${targetHome}" (seeded from "${sourceHome}").\n`,
|
||||
);
|
||||
return targetHome;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
@@ -18,9 +17,11 @@ import {
|
||||
listPaperclipSkillEntries,
|
||||
removeMaintainerOnlySkillSymlinks,
|
||||
renderTemplate,
|
||||
joinPromptSections,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
|
||||
import { pathExists, prepareWorktreeCodexHome, resolveCodexHomeDir } from "./codex-home.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CODEX_ROLLOUT_NOISE_RE =
|
||||
@@ -60,10 +61,32 @@ function resolveCodexBillingType(env: Record<string, string>): "api" | "subscrip
|
||||
return hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription";
|
||||
}
|
||||
|
||||
function codexHomeDir(): string {
|
||||
const fromEnv = process.env.CODEX_HOME;
|
||||
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();
|
||||
return path.join(os.homedir(), ".codex");
|
||||
async function isLikelyPaperclipRepoRoot(candidate: string): Promise<boolean> {
|
||||
const [hasWorkspace, hasPackageJson, hasServerDir, hasAdapterUtilsDir] = await Promise.all([
|
||||
pathExists(path.join(candidate, "pnpm-workspace.yaml")),
|
||||
pathExists(path.join(candidate, "package.json")),
|
||||
pathExists(path.join(candidate, "server")),
|
||||
pathExists(path.join(candidate, "packages", "adapter-utils")),
|
||||
]);
|
||||
|
||||
return hasWorkspace && hasPackageJson && hasServerDir && hasAdapterUtilsDir;
|
||||
}
|
||||
|
||||
async function isLikelyPaperclipRuntimeSkillSource(candidate: string, skillName: string): Promise<boolean> {
|
||||
if (path.basename(candidate) !== skillName) return false;
|
||||
const skillsRoot = path.dirname(candidate);
|
||||
if (path.basename(skillsRoot) !== "skills") return false;
|
||||
if (!(await pathExists(path.join(candidate, "SKILL.md")))) return false;
|
||||
|
||||
let cursor = path.dirname(skillsRoot);
|
||||
for (let depth = 0; depth < 6; depth += 1) {
|
||||
if (await isLikelyPaperclipRepoRoot(cursor)) return true;
|
||||
const parent = path.dirname(cursor);
|
||||
if (parent === cursor) break;
|
||||
cursor = parent;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
type EnsureCodexSkillsInjectedOptions = {
|
||||
@@ -79,7 +102,7 @@ export async function ensureCodexSkillsInjected(
|
||||
const skillsEntries = options.skillsEntries ?? await listPaperclipSkillEntries(__moduleDir);
|
||||
if (skillsEntries.length === 0) return;
|
||||
|
||||
const skillsHome = options.skillsHome ?? path.join(codexHomeDir(), "skills");
|
||||
const skillsHome = options.skillsHome ?? path.join(resolveCodexHomeDir(process.env), "skills");
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
const removedSkills = await removeMaintainerOnlySkillSymlinks(
|
||||
skillsHome,
|
||||
@@ -87,7 +110,7 @@ export async function ensureCodexSkillsInjected(
|
||||
);
|
||||
for (const skillName of removedSkills) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
"stdout",
|
||||
`[paperclip] Removed maintainer-only Codex skill "${skillName}" from ${skillsHome}\n`,
|
||||
);
|
||||
}
|
||||
@@ -96,11 +119,36 @@ export async function ensureCodexSkillsInjected(
|
||||
const target = path.join(skillsHome, entry.name);
|
||||
|
||||
try {
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (existing?.isSymbolicLink()) {
|
||||
const linkedPath = await fs.readlink(target).catch(() => null);
|
||||
const resolvedLinkedPath = linkedPath
|
||||
? path.resolve(path.dirname(target), linkedPath)
|
||||
: null;
|
||||
if (
|
||||
resolvedLinkedPath &&
|
||||
resolvedLinkedPath !== entry.source &&
|
||||
(await isLikelyPaperclipRuntimeSkillSource(resolvedLinkedPath, entry.name))
|
||||
) {
|
||||
await fs.unlink(target);
|
||||
if (linkSkill) {
|
||||
await linkSkill(entry.source, target);
|
||||
} else {
|
||||
await fs.symlink(entry.source, target);
|
||||
}
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Repaired Codex skill "${entry.name}" into ${skillsHome}\n`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await ensurePaperclipSkillSymlink(entry.source, target, linkSkill);
|
||||
if (result === "skipped") continue;
|
||||
|
||||
await onLog(
|
||||
"stderr",
|
||||
"stdout",
|
||||
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Codex skill "${entry.name}" into ${skillsHome}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -140,6 +188,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||
const workspaceBranch = asString(workspaceContext.branchName, "");
|
||||
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "");
|
||||
const agentHome = asString(workspaceContext.agentHome, "");
|
||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
@@ -160,12 +209,25 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
await ensureCodexSkillsInjected(onLog);
|
||||
const envConfig = parseObject(config.env);
|
||||
const configuredCodexHome =
|
||||
typeof envConfig.CODEX_HOME === "string" && envConfig.CODEX_HOME.trim().length > 0
|
||||
? path.resolve(envConfig.CODEX_HOME.trim())
|
||||
: null;
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
const preparedWorktreeCodexHome =
|
||||
configuredCodexHome ? null : await prepareWorktreeCodexHome(process.env, onLog);
|
||||
const effectiveCodexHome = configuredCodexHome ?? preparedWorktreeCodexHome;
|
||||
await ensureCodexSkillsInjected(
|
||||
onLog,
|
||||
effectiveCodexHome ? { skillsHome: path.join(effectiveCodexHome, "skills") } : {},
|
||||
);
|
||||
const hasExplicitApiKey =
|
||||
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
||||
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||
if (effectiveCodexHome) {
|
||||
env.CODEX_HOME = effectiveCodexHome;
|
||||
}
|
||||
env.PAPERCLIP_RUN_ID = runId;
|
||||
const wakeTaskId =
|
||||
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
||||
@@ -232,6 +294,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (workspaceWorktreePath) {
|
||||
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
|
||||
}
|
||||
if (agentHome) {
|
||||
env.AGENT_HOME = agentHome;
|
||||
}
|
||||
if (workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
}
|
||||
@@ -278,6 +343,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
||||
let instructionsPrefix = "";
|
||||
let instructionsChars = 0;
|
||||
if (instructionsFilePath) {
|
||||
try {
|
||||
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
|
||||
@@ -285,8 +351,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
`${instructionsContents}\n\n` +
|
||||
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||
instructionsChars = instructionsPrefix.length;
|
||||
await onLog(
|
||||
"stderr",
|
||||
"stdout",
|
||||
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -309,7 +376,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||
];
|
||||
})();
|
||||
const renderedPrompt = renderTemplate(promptTemplate, {
|
||||
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
||||
const templateData = {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId,
|
||||
@@ -317,8 +385,26 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
agent,
|
||||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
});
|
||||
const prompt = `${instructionsPrefix}${renderedPrompt}`;
|
||||
};
|
||||
const renderedPrompt = renderTemplate(promptTemplate, templateData);
|
||||
const renderedBootstrapPrompt =
|
||||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||
const prompt = joinPromptSections([
|
||||
instructionsPrefix,
|
||||
renderedBootstrapPrompt,
|
||||
sessionHandoffNote,
|
||||
renderedPrompt,
|
||||
]);
|
||||
const promptMetrics = {
|
||||
promptChars: prompt.length,
|
||||
instructionsChars,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
};
|
||||
|
||||
const buildArgs = (resumeSessionId: string | null) => {
|
||||
const args = ["exec", "--json"];
|
||||
@@ -346,6 +432,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
}),
|
||||
env: redactEnvForLogs(env),
|
||||
prompt,
|
||||
promptMetrics,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||
ac.model = v.model || DEFAULT_CODEX_LOCAL_MODEL;
|
||||
if (v.thinkingEffort) ac.modelReasoningEffort = v.thinkingEffort;
|
||||
ac.timeoutSec = 0;
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @paperclipai/adapter-cursor-local
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stable release preparation for 0.3.1
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.1
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-cursor-local",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
listPaperclipSkillEntries,
|
||||
removeMaintainerOnlySkillSymlinks,
|
||||
renderTemplate,
|
||||
joinPromptSections,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
|
||||
@@ -156,6 +157,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "");
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||
const agentHome = asString(workspaceContext.agentHome, "");
|
||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
@@ -229,6 +231,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (workspaceRepoRef) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
}
|
||||
if (agentHome) {
|
||||
env.AGENT_HOME = agentHome;
|
||||
}
|
||||
if (workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
}
|
||||
@@ -268,6 +273,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
||||
let instructionsPrefix = "";
|
||||
let instructionsChars = 0;
|
||||
if (instructionsFilePath) {
|
||||
try {
|
||||
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
|
||||
@@ -275,6 +281,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
`${instructionsContents}\n\n` +
|
||||
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||
instructionsChars = instructionsPrefix.length;
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
|
||||
@@ -307,7 +314,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
return notes;
|
||||
})();
|
||||
|
||||
const renderedPrompt = renderTemplate(promptTemplate, {
|
||||
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
||||
const templateData = {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId,
|
||||
@@ -315,9 +323,29 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
agent,
|
||||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
});
|
||||
};
|
||||
const renderedPrompt = renderTemplate(promptTemplate, templateData);
|
||||
const renderedBootstrapPrompt =
|
||||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||
const paperclipEnvNote = renderPaperclipEnvNote(env);
|
||||
const prompt = `${instructionsPrefix}${paperclipEnvNote}${renderedPrompt}`;
|
||||
const prompt = joinPromptSections([
|
||||
instructionsPrefix,
|
||||
renderedBootstrapPrompt,
|
||||
sessionHandoffNote,
|
||||
paperclipEnvNote,
|
||||
renderedPrompt,
|
||||
]);
|
||||
const promptMetrics = {
|
||||
promptChars: prompt.length,
|
||||
instructionsChars,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
runtimeNoteChars: paperclipEnvNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
};
|
||||
|
||||
const buildArgs = (resumeSessionId: string | null) => {
|
||||
const args = ["-p", "--output-format", "stream-json", "--workspace", cwd];
|
||||
@@ -340,6 +368,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
commandArgs: args,
|
||||
env: redactEnvForLogs(env),
|
||||
prompt,
|
||||
promptMetrics,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ export function buildCursorLocalConfig(v: CreateConfigValues): Record<string, un
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||
ac.model = v.model || DEFAULT_CURSOR_LOCAL_MODEL;
|
||||
const mode = normalizeMode(v.thinkingEffort);
|
||||
if (mode) ac.mode = mode;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-gemini-local",
|
||||
"version": "0.2.7",
|
||||
"version": "0.3.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePaperclipSkillSymlink,
|
||||
joinPromptSections,
|
||||
ensurePathInEnv,
|
||||
listPaperclipSkillEntries,
|
||||
removeMaintainerOnlySkillSymlinks,
|
||||
@@ -144,6 +145,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "");
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||
const agentHome = asString(workspaceContext.agentHome, "");
|
||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
@@ -195,6 +197,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
if (agentHome) env.AGENT_HOME = agentHome;
|
||||
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
@@ -268,7 +271,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
return notes;
|
||||
})();
|
||||
|
||||
const renderedPrompt = renderTemplate(promptTemplate, {
|
||||
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
||||
const templateData = {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId,
|
||||
@@ -276,10 +280,31 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
agent,
|
||||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
});
|
||||
};
|
||||
const renderedPrompt = renderTemplate(promptTemplate, templateData);
|
||||
const renderedBootstrapPrompt =
|
||||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||
const paperclipEnvNote = renderPaperclipEnvNote(env);
|
||||
const apiAccessNote = renderApiAccessNote(env);
|
||||
const prompt = `${instructionsPrefix}${paperclipEnvNote}${apiAccessNote}${renderedPrompt}`;
|
||||
const prompt = joinPromptSections([
|
||||
instructionsPrefix,
|
||||
renderedBootstrapPrompt,
|
||||
sessionHandoffNote,
|
||||
paperclipEnvNote,
|
||||
apiAccessNote,
|
||||
renderedPrompt,
|
||||
]);
|
||||
const promptMetrics = {
|
||||
promptChars: prompt.length,
|
||||
instructionsChars: instructionsPrefix.length,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
};
|
||||
|
||||
const buildArgs = (resumeSessionId: string | null) => {
|
||||
const args = ["--output-format", "stream-json"];
|
||||
@@ -309,6 +334,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
)),
|
||||
env: redactEnvForLogs(env),
|
||||
prompt,
|
||||
promptMetrics,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ export function buildGeminiLocalConfig(v: CreateConfigValues): Record<string, un
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||
ac.model = v.model || DEFAULT_GEMINI_LOCAL_MODEL;
|
||||
ac.timeoutSec = 0;
|
||||
ac.graceSec = 15;
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @paperclipai/adapter-openclaw-gateway
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stable release preparation for 0.3.1
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.1
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-openclaw-gateway",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @paperclipai/adapter-opencode-local
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stable release preparation for 0.3.1
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.1
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-opencode-local",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
asStringArray,
|
||||
parseObject,
|
||||
buildPaperclipEnv,
|
||||
joinPromptSections,
|
||||
redactEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
@@ -99,6 +100,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "");
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||
const agentHome = asString(workspaceContext.agentHome, "");
|
||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
@@ -150,6 +152,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
if (agentHome) env.AGENT_HOME = agentHome;
|
||||
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
@@ -233,7 +236,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
];
|
||||
})();
|
||||
|
||||
const renderedPrompt = renderTemplate(promptTemplate, {
|
||||
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
||||
const templateData = {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId,
|
||||
@@ -241,8 +245,26 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
agent,
|
||||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
});
|
||||
const prompt = `${instructionsPrefix}${renderedPrompt}`;
|
||||
};
|
||||
const renderedPrompt = renderTemplate(promptTemplate, templateData);
|
||||
const renderedBootstrapPrompt =
|
||||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||
const prompt = joinPromptSections([
|
||||
instructionsPrefix,
|
||||
renderedBootstrapPrompt,
|
||||
sessionHandoffNote,
|
||||
renderedPrompt,
|
||||
]);
|
||||
const promptMetrics = {
|
||||
promptChars: prompt.length,
|
||||
instructionsChars: instructionsPrefix.length,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
};
|
||||
|
||||
const buildArgs = (resumeSessionId: string | null) => {
|
||||
const args = ["run", "--format", "json"];
|
||||
@@ -264,6 +286,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
|
||||
env: redactEnvForLogs(env),
|
||||
prompt,
|
||||
promptMetrics,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record<string,
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||
if (v.model) ac.model = v.model;
|
||||
if (v.thinkingEffort) ac.variant = v.thinkingEffort;
|
||||
// OpenCode sessions can run until the CLI exits naturally; keep timeout disabled (0)
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @paperclipai/adapter-pi-local
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stable release preparation for 0.3.1
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.1
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-pi-local",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
asStringArray,
|
||||
parseObject,
|
||||
buildPaperclipEnv,
|
||||
joinPromptSections,
|
||||
redactEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
@@ -116,6 +117,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "");
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||
const agentHome = asString(workspaceContext.agentHome, "");
|
||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
@@ -175,6 +177,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
if (agentHome) env.AGENT_HOME = agentHome;
|
||||
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
@@ -270,7 +273,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
systemPromptExtension = promptTemplate;
|
||||
}
|
||||
|
||||
const renderedSystemPromptExtension = renderTemplate(systemPromptExtension, {
|
||||
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
||||
const templateData = {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId,
|
||||
@@ -278,18 +282,26 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
agent,
|
||||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
});
|
||||
|
||||
// User prompt is simple - just the rendered prompt template without instructions
|
||||
const userPrompt = renderTemplate(promptTemplate, {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId,
|
||||
company: { id: agent.companyId },
|
||||
agent,
|
||||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
});
|
||||
};
|
||||
const renderedSystemPromptExtension = renderTemplate(systemPromptExtension, templateData);
|
||||
const renderedHeartbeatPrompt = renderTemplate(promptTemplate, templateData);
|
||||
const renderedBootstrapPrompt =
|
||||
!canResumeSession && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||
const userPrompt = joinPromptSections([
|
||||
renderedBootstrapPrompt,
|
||||
sessionHandoffNote,
|
||||
renderedHeartbeatPrompt,
|
||||
]);
|
||||
const promptMetrics = {
|
||||
systemPromptChars: renderedSystemPromptExtension.length,
|
||||
promptChars: userPrompt.length,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
heartbeatPromptChars: renderedHeartbeatPrompt.length,
|
||||
};
|
||||
|
||||
const commandNotes = (() => {
|
||||
if (!resolvedInstructionsFilePath) return [] as string[];
|
||||
@@ -345,6 +357,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
commandArgs: args,
|
||||
env: redactEnvForLogs(env),
|
||||
prompt: userPrompt,
|
||||
promptMetrics,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ export function buildPiLocalConfig(v: CreateConfigValues): Record<string, unknow
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||
if (v.model) ac.model = v.model;
|
||||
if (v.thinkingEffort) ac.thinking = v.thinkingEffort;
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @paperclipai/db
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stable release preparation for 0.3.1
|
||||
- Updated dependencies
|
||||
- @paperclipai/shared@0.3.1
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/db",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
54
packages/db/src/migrations/0028_harsh_goliath.sql
Normal file
54
packages/db/src/migrations/0028_harsh_goliath.sql
Normal file
@@ -0,0 +1,54 @@
|
||||
CREATE TABLE "document_revisions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"document_id" uuid NOT NULL,
|
||||
"revision_number" integer NOT NULL,
|
||||
"body" text NOT NULL,
|
||||
"change_summary" text,
|
||||
"created_by_agent_id" uuid,
|
||||
"created_by_user_id" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "documents" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"title" text,
|
||||
"format" text DEFAULT 'markdown' NOT NULL,
|
||||
"latest_body" text NOT NULL,
|
||||
"latest_revision_id" uuid,
|
||||
"latest_revision_number" integer DEFAULT 1 NOT NULL,
|
||||
"created_by_agent_id" uuid,
|
||||
"created_by_user_id" text,
|
||||
"updated_by_agent_id" uuid,
|
||||
"updated_by_user_id" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "issue_documents" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"issue_id" uuid NOT NULL,
|
||||
"document_id" uuid NOT NULL,
|
||||
"key" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "document_revisions" ADD CONSTRAINT "document_revisions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "document_revisions" ADD CONSTRAINT "document_revisions_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "document_revisions" ADD CONSTRAINT "document_revisions_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "documents" ADD CONSTRAINT "documents_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "documents" ADD CONSTRAINT "documents_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "documents" ADD CONSTRAINT "documents_updated_by_agent_id_agents_id_fk" FOREIGN KEY ("updated_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "issue_documents" ADD CONSTRAINT "issue_documents_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "issue_documents" ADD CONSTRAINT "issue_documents_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "issue_documents" ADD CONSTRAINT "issue_documents_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "document_revisions_document_revision_uq" ON "document_revisions" USING btree ("document_id","revision_number");--> statement-breakpoint
|
||||
CREATE INDEX "document_revisions_company_document_created_idx" ON "document_revisions" USING btree ("company_id","document_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "documents_company_updated_idx" ON "documents" USING btree ("company_id","updated_at");--> statement-breakpoint
|
||||
CREATE INDEX "documents_company_created_idx" ON "documents" USING btree ("company_id","created_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "issue_documents_company_issue_key_uq" ON "issue_documents" USING btree ("company_id","issue_id","key");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "issue_documents_document_uq" ON "issue_documents" USING btree ("document_id");--> statement-breakpoint
|
||||
CREATE INDEX "issue_documents_company_issue_updated_idx" ON "issue_documents" USING btree ("company_id","issue_id","updated_at");
|
||||
177
packages/db/src/migrations/0029_plugin_tables.sql
Normal file
177
packages/db/src/migrations/0029_plugin_tables.sql
Normal file
@@ -0,0 +1,177 @@
|
||||
-- Rollback:
|
||||
-- DROP INDEX IF EXISTS "plugin_logs_level_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_logs_plugin_time_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_company_settings_company_plugin_uq";
|
||||
-- DROP INDEX IF EXISTS "plugin_company_settings_plugin_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_company_settings_company_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_key_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_status_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_plugin_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_job_runs_status_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_job_runs_plugin_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_job_runs_job_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_jobs_unique_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_jobs_next_run_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_jobs_plugin_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_entities_external_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_entities_scope_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_entities_type_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_entities_plugin_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_state_plugin_scope_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_config_plugin_id_idx";
|
||||
-- DROP INDEX IF EXISTS "plugins_status_idx";
|
||||
-- DROP INDEX IF EXISTS "plugins_plugin_key_idx";
|
||||
-- DROP TABLE IF EXISTS "plugin_logs";
|
||||
-- DROP TABLE IF EXISTS "plugin_company_settings";
|
||||
-- DROP TABLE IF EXISTS "plugin_webhook_deliveries";
|
||||
-- DROP TABLE IF EXISTS "plugin_job_runs";
|
||||
-- DROP TABLE IF EXISTS "plugin_jobs";
|
||||
-- DROP TABLE IF EXISTS "plugin_entities";
|
||||
-- DROP TABLE IF EXISTS "plugin_state";
|
||||
-- DROP TABLE IF EXISTS "plugin_config";
|
||||
-- DROP TABLE IF EXISTS "plugins";
|
||||
|
||||
CREATE TABLE "plugins" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"plugin_key" text NOT NULL,
|
||||
"package_name" text NOT NULL,
|
||||
"package_path" text,
|
||||
"version" text NOT NULL,
|
||||
"api_version" integer DEFAULT 1 NOT NULL,
|
||||
"categories" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"manifest_json" jsonb NOT NULL,
|
||||
"status" text DEFAULT 'installed' NOT NULL,
|
||||
"install_order" integer,
|
||||
"last_error" text,
|
||||
"installed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "plugin_config" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"plugin_id" uuid NOT NULL,
|
||||
"config_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"last_error" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "plugin_state" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"plugin_id" uuid NOT NULL,
|
||||
"scope_kind" text NOT NULL,
|
||||
"scope_id" text,
|
||||
"namespace" text DEFAULT 'default' NOT NULL,
|
||||
"state_key" text NOT NULL,
|
||||
"value_json" jsonb NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "plugin_state_unique_entry_idx" UNIQUE NULLS NOT DISTINCT("plugin_id","scope_kind","scope_id","namespace","state_key")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "plugin_entities" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"plugin_id" uuid NOT NULL,
|
||||
"entity_type" text NOT NULL,
|
||||
"scope_kind" text NOT NULL,
|
||||
"scope_id" text,
|
||||
"external_id" text,
|
||||
"title" text,
|
||||
"status" text,
|
||||
"data" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "plugin_jobs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"plugin_id" uuid NOT NULL,
|
||||
"job_key" text NOT NULL,
|
||||
"schedule" text NOT NULL,
|
||||
"status" text DEFAULT 'active' NOT NULL,
|
||||
"last_run_at" timestamp with time zone,
|
||||
"next_run_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "plugin_job_runs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"job_id" uuid NOT NULL,
|
||||
"plugin_id" uuid NOT NULL,
|
||||
"trigger" text NOT NULL,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"duration_ms" integer,
|
||||
"error" text,
|
||||
"logs" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"started_at" timestamp with time zone,
|
||||
"finished_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "plugin_webhook_deliveries" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"plugin_id" uuid NOT NULL,
|
||||
"webhook_key" text NOT NULL,
|
||||
"external_id" text,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"duration_ms" integer,
|
||||
"error" text,
|
||||
"payload" jsonb NOT NULL,
|
||||
"headers" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"started_at" timestamp with time zone,
|
||||
"finished_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "plugin_company_settings" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"plugin_id" uuid NOT NULL,
|
||||
"settings_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"last_error" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"enabled" boolean DEFAULT true NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "plugin_logs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"plugin_id" uuid NOT NULL,
|
||||
"level" text NOT NULL DEFAULT 'info',
|
||||
"message" text NOT NULL,
|
||||
"meta" jsonb,
|
||||
"created_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "plugin_config" ADD CONSTRAINT "plugin_config_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_state" ADD CONSTRAINT "plugin_state_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_entities" ADD CONSTRAINT "plugin_entities_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_jobs" ADD CONSTRAINT "plugin_jobs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_job_runs" ADD CONSTRAINT "plugin_job_runs_job_id_plugin_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "public"."plugin_jobs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_job_runs" ADD CONSTRAINT "plugin_job_runs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_webhook_deliveries" ADD CONSTRAINT "plugin_webhook_deliveries_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_company_settings" ADD CONSTRAINT "plugin_company_settings_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_company_settings" ADD CONSTRAINT "plugin_company_settings_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_logs" ADD CONSTRAINT "plugin_logs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "plugins_plugin_key_idx" ON "plugins" USING btree ("plugin_key");--> statement-breakpoint
|
||||
CREATE INDEX "plugins_status_idx" ON "plugins" USING btree ("status");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "plugin_config_plugin_id_idx" ON "plugin_config" USING btree ("plugin_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_state_plugin_scope_idx" ON "plugin_state" USING btree ("plugin_id","scope_kind");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_entities_plugin_idx" ON "plugin_entities" USING btree ("plugin_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_entities_type_idx" ON "plugin_entities" USING btree ("entity_type");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_entities_scope_idx" ON "plugin_entities" USING btree ("scope_kind","scope_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "plugin_entities_external_idx" ON "plugin_entities" USING btree ("plugin_id","entity_type","external_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_jobs_plugin_idx" ON "plugin_jobs" USING btree ("plugin_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_jobs_next_run_idx" ON "plugin_jobs" USING btree ("next_run_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "plugin_jobs_unique_idx" ON "plugin_jobs" USING btree ("plugin_id","job_key");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_job_runs_job_idx" ON "plugin_job_runs" USING btree ("job_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_job_runs_plugin_idx" ON "plugin_job_runs" USING btree ("plugin_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_job_runs_status_idx" ON "plugin_job_runs" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_webhook_deliveries_plugin_idx" ON "plugin_webhook_deliveries" USING btree ("plugin_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_webhook_deliveries_status_idx" ON "plugin_webhook_deliveries" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_webhook_deliveries_key_idx" ON "plugin_webhook_deliveries" USING btree ("webhook_key");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_company_settings_company_idx" ON "plugin_company_settings" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_company_settings_plugin_idx" ON "plugin_company_settings" USING btree ("plugin_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "plugin_company_settings_company_plugin_uq" ON "plugin_company_settings" USING btree ("company_id","plugin_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_logs_plugin_time_idx" ON "plugin_logs" USING btree ("plugin_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_logs_level_idx" ON "plugin_logs" USING btree ("level");
|
||||
6710
packages/db/src/migrations/meta/0028_snapshot.json
Normal file
6710
packages/db/src/migrations/meta/0028_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7899
packages/db/src/migrations/meta/0029_snapshot.json
Normal file
7899
packages/db/src/migrations/meta/0029_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -197,6 +197,20 @@
|
||||
"when": 1773150731736,
|
||||
"tag": "0027_tranquil_tenebrous",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 28,
|
||||
"version": "7",
|
||||
"when": 1773432085646,
|
||||
"tag": "0028_harsh_goliath",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 29,
|
||||
"version": "7",
|
||||
"when": 1773417600000,
|
||||
"tag": "0029_plugin_tables",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
30
packages/db/src/schema/document_revisions.ts
Normal file
30
packages/db/src/schema/document_revisions.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { pgTable, uuid, text, integer, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { agents } from "./agents.js";
|
||||
import { documents } from "./documents.js";
|
||||
|
||||
export const documentRevisions = pgTable(
|
||||
"document_revisions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
||||
revisionNumber: integer("revision_number").notNull(),
|
||||
body: text("body").notNull(),
|
||||
changeSummary: text("change_summary"),
|
||||
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
createdByUserId: text("created_by_user_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
documentRevisionUq: uniqueIndex("document_revisions_document_revision_uq").on(
|
||||
table.documentId,
|
||||
table.revisionNumber,
|
||||
),
|
||||
companyDocumentCreatedIdx: index("document_revisions_company_document_created_idx").on(
|
||||
table.companyId,
|
||||
table.documentId,
|
||||
table.createdAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
26
packages/db/src/schema/documents.ts
Normal file
26
packages/db/src/schema/documents.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { pgTable, uuid, text, integer, timestamp, index } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { agents } from "./agents.js";
|
||||
|
||||
export const documents = pgTable(
|
||||
"documents",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
title: text("title"),
|
||||
format: text("format").notNull().default("markdown"),
|
||||
latestBody: text("latest_body").notNull(),
|
||||
latestRevisionId: uuid("latest_revision_id"),
|
||||
latestRevisionNumber: integer("latest_revision_number").notNull().default(1),
|
||||
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
createdByUserId: text("created_by_user_id"),
|
||||
updatedByAgentId: uuid("updated_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
updatedByUserId: text("updated_by_user_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyUpdatedIdx: index("documents_company_updated_idx").on(table.companyId, table.updatedAt),
|
||||
companyCreatedIdx: index("documents_company_created_idx").on(table.companyId, table.createdAt),
|
||||
}),
|
||||
);
|
||||
@@ -24,6 +24,9 @@ export { issueComments } from "./issue_comments.js";
|
||||
export { issueReadStates } from "./issue_read_states.js";
|
||||
export { assets } from "./assets.js";
|
||||
export { issueAttachments } from "./issue_attachments.js";
|
||||
export { documents } from "./documents.js";
|
||||
export { documentRevisions } from "./document_revisions.js";
|
||||
export { issueDocuments } from "./issue_documents.js";
|
||||
export { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
export { heartbeatRunEvents } from "./heartbeat_run_events.js";
|
||||
export { costEvents } from "./cost_events.js";
|
||||
@@ -32,3 +35,11 @@ export { approvalComments } from "./approval_comments.js";
|
||||
export { activityLog } from "./activity_log.js";
|
||||
export { companySecrets } from "./company_secrets.js";
|
||||
export { companySecretVersions } from "./company_secret_versions.js";
|
||||
export { plugins } from "./plugins.js";
|
||||
export { pluginConfig } from "./plugin_config.js";
|
||||
export { pluginCompanySettings } from "./plugin_company_settings.js";
|
||||
export { pluginState } from "./plugin_state.js";
|
||||
export { pluginEntities } from "./plugin_entities.js";
|
||||
export { pluginJobs, pluginJobRuns } from "./plugin_jobs.js";
|
||||
export { pluginWebhookDeliveries } from "./plugin_webhooks.js";
|
||||
export { pluginLogs } from "./plugin_logs.js";
|
||||
|
||||
30
packages/db/src/schema/issue_documents.ts
Normal file
30
packages/db/src/schema/issue_documents.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { documents } from "./documents.js";
|
||||
|
||||
export const issueDocuments = pgTable(
|
||||
"issue_documents",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
|
||||
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
||||
key: text("key").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyIssueKeyUq: uniqueIndex("issue_documents_company_issue_key_uq").on(
|
||||
table.companyId,
|
||||
table.issueId,
|
||||
table.key,
|
||||
),
|
||||
documentUq: uniqueIndex("issue_documents_document_uq").on(table.documentId),
|
||||
companyIssueUpdatedIdx: index("issue_documents_company_issue_updated_idx").on(
|
||||
table.companyId,
|
||||
table.issueId,
|
||||
table.updatedAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
41
packages/db/src/schema/plugin_company_settings.ts
Normal file
41
packages/db/src/schema/plugin_company_settings.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex, boolean } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { plugins } from "./plugins.js";
|
||||
|
||||
/**
|
||||
* `plugin_company_settings` table — stores operator-managed plugin settings
|
||||
* scoped to a specific company.
|
||||
*
|
||||
* This is distinct from `plugin_config`, which stores instance-wide plugin
|
||||
* configuration. Each company can have at most one settings row per plugin.
|
||||
*
|
||||
* Rows represent explicit overrides from the default company behavior:
|
||||
* - no row => plugin is enabled for the company by default
|
||||
* - row with `enabled = false` => plugin is disabled for that company
|
||||
* - row with `enabled = true` => plugin remains enabled and stores company settings
|
||||
*/
|
||||
export const pluginCompanySettings = pgTable(
|
||||
"plugin_company_settings",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id")
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: "cascade" }),
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
settingsJson: jsonb("settings_json").$type<Record<string, unknown>>().notNull().default({}),
|
||||
lastError: text("last_error"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyIdx: index("plugin_company_settings_company_idx").on(table.companyId),
|
||||
pluginIdx: index("plugin_company_settings_plugin_idx").on(table.pluginId),
|
||||
companyPluginUq: uniqueIndex("plugin_company_settings_company_plugin_uq").on(
|
||||
table.companyId,
|
||||
table.pluginId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
30
packages/db/src/schema/plugin_config.ts
Normal file
30
packages/db/src/schema/plugin_config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { plugins } from "./plugins.js";
|
||||
|
||||
/**
|
||||
* `plugin_config` table — stores operator-provided instance configuration
|
||||
* for each plugin (one row per plugin, enforced by a unique index on
|
||||
* `plugin_id`).
|
||||
*
|
||||
* The `config_json` column holds the values that the operator enters in the
|
||||
* plugin settings UI. These values are validated at runtime against the
|
||||
* plugin's `instanceConfigSchema` from the manifest.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3
|
||||
*/
|
||||
export const pluginConfig = pgTable(
|
||||
"plugin_config",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
configJson: jsonb("config_json").$type<Record<string, unknown>>().notNull().default({}),
|
||||
lastError: text("last_error"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pluginIdIdx: uniqueIndex("plugin_config_plugin_id_idx").on(table.pluginId),
|
||||
}),
|
||||
);
|
||||
54
packages/db/src/schema/plugin_entities.ts
Normal file
54
packages/db/src/schema/plugin_entities.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { plugins } from "./plugins.js";
|
||||
import type { PluginStateScopeKind } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* `plugin_entities` table — persistent high-level mapping between Paperclip
|
||||
* objects and external plugin-defined entities.
|
||||
*
|
||||
* This table is used by plugins (e.g. `linear`, `github`) to store pointers
|
||||
* to their respective external IDs for projects, issues, etc. and to store
|
||||
* their custom data.
|
||||
*
|
||||
* Unlike `plugin_state`, which is for raw K-V persistence, `plugin_entities`
|
||||
* is intended for structured object mappings that the host can understand
|
||||
* and query for cross-plugin UI integration.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3
|
||||
*/
|
||||
export const pluginEntities = pgTable(
|
||||
"plugin_entities",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
entityType: text("entity_type").notNull(),
|
||||
scopeKind: text("scope_kind").$type<PluginStateScopeKind>().notNull(),
|
||||
scopeId: text("scope_id"), // NULL for global scope (text to match plugin_state.scope_id)
|
||||
externalId: text("external_id"), // ID in the external system
|
||||
title: text("title"),
|
||||
status: text("status"),
|
||||
data: jsonb("data").$type<Record<string, unknown>>().notNull().default({}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pluginIdx: index("plugin_entities_plugin_idx").on(table.pluginId),
|
||||
typeIdx: index("plugin_entities_type_idx").on(table.entityType),
|
||||
scopeIdx: index("plugin_entities_scope_idx").on(table.scopeKind, table.scopeId),
|
||||
externalIdx: uniqueIndex("plugin_entities_external_idx").on(
|
||||
table.pluginId,
|
||||
table.entityType,
|
||||
table.externalId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
102
packages/db/src/schema/plugin_jobs.ts
Normal file
102
packages/db/src/schema/plugin_jobs.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
integer,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { plugins } from "./plugins.js";
|
||||
import type { PluginJobStatus, PluginJobRunStatus, PluginJobRunTrigger } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* `plugin_jobs` table — registration and runtime configuration for
|
||||
* scheduled jobs declared by plugins in their manifests.
|
||||
*
|
||||
* Each row represents one scheduled job entry for a plugin. The
|
||||
* `job_key` matches the key declared in the manifest's `jobs` array.
|
||||
* The `schedule` column stores the cron expression or interval string
|
||||
* used by the job scheduler to decide when to fire the job.
|
||||
*
|
||||
* Status values:
|
||||
* - `active` — job is enabled and will run on schedule
|
||||
* - `paused` — job is temporarily disabled by the operator
|
||||
* - `error` — job has been disabled due to repeated failures
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3 — `plugin_jobs`
|
||||
*/
|
||||
export const pluginJobs = pgTable(
|
||||
"plugin_jobs",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
/** FK to the owning plugin. Cascades on delete. */
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
/** Identifier matching the key in the plugin manifest's `jobs` array. */
|
||||
jobKey: text("job_key").notNull(),
|
||||
/** Cron expression (e.g. `"0 * * * *"`) or interval string. */
|
||||
schedule: text("schedule").notNull(),
|
||||
/** Current scheduling state. */
|
||||
status: text("status").$type<PluginJobStatus>().notNull().default("active"),
|
||||
/** Timestamp of the most recent successful execution. */
|
||||
lastRunAt: timestamp("last_run_at", { withTimezone: true }),
|
||||
/** Pre-computed timestamp of the next scheduled execution. */
|
||||
nextRunAt: timestamp("next_run_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pluginIdx: index("plugin_jobs_plugin_idx").on(table.pluginId),
|
||||
nextRunIdx: index("plugin_jobs_next_run_idx").on(table.nextRunAt),
|
||||
uniqueJobIdx: uniqueIndex("plugin_jobs_unique_idx").on(table.pluginId, table.jobKey),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* `plugin_job_runs` table — immutable execution history for plugin-owned jobs.
|
||||
*
|
||||
* Each row is created when a job run begins and updated when it completes.
|
||||
* Rows are never modified after `status` reaches a terminal value
|
||||
* (`succeeded` | `failed` | `cancelled`).
|
||||
*
|
||||
* Trigger values:
|
||||
* - `scheduled` — fired automatically by the cron/interval scheduler
|
||||
* - `manual` — triggered by an operator via the admin UI or API
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3 — `plugin_job_runs`
|
||||
*/
|
||||
export const pluginJobRuns = pgTable(
|
||||
"plugin_job_runs",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
/** FK to the parent job definition. Cascades on delete. */
|
||||
jobId: uuid("job_id")
|
||||
.notNull()
|
||||
.references(() => pluginJobs.id, { onDelete: "cascade" }),
|
||||
/** Denormalized FK to the owning plugin for efficient querying. Cascades on delete. */
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
/** What caused this run to start (`"scheduled"` or `"manual"`). */
|
||||
trigger: text("trigger").$type<PluginJobRunTrigger>().notNull(),
|
||||
/** Current lifecycle state of this run. */
|
||||
status: text("status").$type<PluginJobRunStatus>().notNull().default("pending"),
|
||||
/** Wall-clock duration in milliseconds. Null until the run finishes. */
|
||||
durationMs: integer("duration_ms"),
|
||||
/** Error message if `status === "failed"`. */
|
||||
error: text("error"),
|
||||
/** Ordered list of log lines emitted during this run. */
|
||||
logs: jsonb("logs").$type<string[]>().notNull().default([]),
|
||||
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||
finishedAt: timestamp("finished_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
jobIdx: index("plugin_job_runs_job_idx").on(table.jobId),
|
||||
pluginIdx: index("plugin_job_runs_plugin_idx").on(table.pluginId),
|
||||
statusIdx: index("plugin_job_runs_status_idx").on(table.status),
|
||||
}),
|
||||
);
|
||||
43
packages/db/src/schema/plugin_logs.ts
Normal file
43
packages/db/src/schema/plugin_logs.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { plugins } from "./plugins.js";
|
||||
|
||||
/**
|
||||
* `plugin_logs` table — structured log storage for plugin workers.
|
||||
*
|
||||
* Each row stores a single log entry emitted by a plugin worker via
|
||||
* `ctx.logger.info(...)` etc. Logs are queryable by plugin, level, and
|
||||
* time range to support the operator logs panel and debugging workflows.
|
||||
*
|
||||
* Rows are inserted by the host when handling `log` notifications from
|
||||
* the worker process. A capped retention policy can be applied via
|
||||
* periodic cleanup (e.g. delete rows older than 7 days).
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §26 — Observability
|
||||
*/
|
||||
export const pluginLogs = pgTable(
|
||||
"plugin_logs",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
level: text("level").notNull().default("info"),
|
||||
message: text("message").notNull(),
|
||||
meta: jsonb("meta").$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pluginTimeIdx: index("plugin_logs_plugin_time_idx").on(
|
||||
table.pluginId,
|
||||
table.createdAt,
|
||||
),
|
||||
levelIdx: index("plugin_logs_level_idx").on(table.level),
|
||||
}),
|
||||
);
|
||||
90
packages/db/src/schema/plugin_state.ts
Normal file
90
packages/db/src/schema/plugin_state.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
unique,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import type { PluginStateScopeKind } from "@paperclipai/shared";
|
||||
import { plugins } from "./plugins.js";
|
||||
|
||||
/**
|
||||
* `plugin_state` table — scoped key-value storage for plugin workers.
|
||||
*
|
||||
* Each row stores a single JSON value identified by
|
||||
* `(plugin_id, scope_kind, scope_id, namespace, state_key)`. Plugins use
|
||||
* this table through `ctx.state.get()`, `ctx.state.set()`, and
|
||||
* `ctx.state.delete()` in the SDK.
|
||||
*
|
||||
* Scope kinds determine the granularity of isolation:
|
||||
* - `instance` — one value shared across the whole Paperclip instance
|
||||
* - `company` — one value per company
|
||||
* - `project` — one value per project
|
||||
* - `project_workspace` — one value per project workspace
|
||||
* - `agent` — one value per agent
|
||||
* - `issue` — one value per issue
|
||||
* - `goal` — one value per goal
|
||||
* - `run` — one value per agent run
|
||||
*
|
||||
* The `namespace` column defaults to `"default"` and can be used to
|
||||
* logically group keys without polluting the root namespace.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3 — `plugin_state`
|
||||
*/
|
||||
export const pluginState = pgTable(
|
||||
"plugin_state",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
/** FK to the owning plugin. Cascades on delete. */
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
/** Granularity of the scope (e.g. `"instance"`, `"project"`, `"issue"`). */
|
||||
scopeKind: text("scope_kind").$type<PluginStateScopeKind>().notNull(),
|
||||
/**
|
||||
* UUID or text identifier for the scoped object.
|
||||
* Null for `instance` scope (which has no associated entity).
|
||||
*/
|
||||
scopeId: text("scope_id"),
|
||||
/**
|
||||
* Sub-namespace to avoid key collisions within a scope.
|
||||
* Defaults to `"default"` if the plugin does not specify one.
|
||||
*/
|
||||
namespace: text("namespace").notNull().default("default"),
|
||||
/** The key identifying this state entry within the namespace. */
|
||||
stateKey: text("state_key").notNull(),
|
||||
/** JSON-serializable value stored by the plugin. */
|
||||
valueJson: jsonb("value_json").notNull(),
|
||||
/** Timestamp of the most recent write. */
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
/**
|
||||
* Unique constraint enforces that there is at most one value per
|
||||
* (plugin, scope kind, scope id, namespace, key) tuple.
|
||||
*
|
||||
* `nullsNotDistinct()` is required so that `scope_id IS NULL` entries
|
||||
* (used by `instance` scope) are treated as equal by PostgreSQL rather
|
||||
* than as distinct nulls — otherwise the upsert target in `set()` would
|
||||
* fail to match existing rows and create duplicates.
|
||||
*
|
||||
* Requires PostgreSQL 15+.
|
||||
*/
|
||||
uniqueEntry: unique("plugin_state_unique_entry_idx")
|
||||
.on(
|
||||
table.pluginId,
|
||||
table.scopeKind,
|
||||
table.scopeId,
|
||||
table.namespace,
|
||||
table.stateKey,
|
||||
)
|
||||
.nullsNotDistinct(),
|
||||
/** Speed up lookups by plugin + scope kind (most common access pattern). */
|
||||
pluginScopeIdx: index("plugin_state_plugin_scope_idx").on(
|
||||
table.pluginId,
|
||||
table.scopeKind,
|
||||
),
|
||||
}),
|
||||
);
|
||||
65
packages/db/src/schema/plugin_webhooks.ts
Normal file
65
packages/db/src/schema/plugin_webhooks.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
integer,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { plugins } from "./plugins.js";
|
||||
import type { PluginWebhookDeliveryStatus } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* `plugin_webhook_deliveries` table — inbound webhook delivery history for plugins.
|
||||
*
|
||||
* When an external system sends an HTTP POST to a plugin's registered webhook
|
||||
* endpoint (e.g. `/api/plugins/:pluginKey/webhooks/:webhookKey`), the server
|
||||
* creates a row in this table before dispatching the payload to the plugin
|
||||
* worker. This provides an auditable log of every delivery attempt.
|
||||
*
|
||||
* The `webhook_key` matches the key declared in the plugin manifest's
|
||||
* `webhooks` array. `external_id` is an optional identifier supplied by the
|
||||
* remote system (e.g. a GitHub delivery GUID) that can be used to detect
|
||||
* and reject duplicate deliveries.
|
||||
*
|
||||
* Status values:
|
||||
* - `pending` — received but not yet dispatched to the worker
|
||||
* - `processing` — currently being handled by the plugin worker
|
||||
* - `succeeded` — worker processed the payload successfully
|
||||
* - `failed` — worker returned an error or timed out
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3 — `plugin_webhook_deliveries`
|
||||
*/
|
||||
export const pluginWebhookDeliveries = pgTable(
|
||||
"plugin_webhook_deliveries",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
/** FK to the owning plugin. Cascades on delete. */
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
/** Identifier matching the key in the plugin manifest's `webhooks` array. */
|
||||
webhookKey: text("webhook_key").notNull(),
|
||||
/** Optional de-duplication ID provided by the external system. */
|
||||
externalId: text("external_id"),
|
||||
/** Current delivery state. */
|
||||
status: text("status").$type<PluginWebhookDeliveryStatus>().notNull().default("pending"),
|
||||
/** Wall-clock processing duration in milliseconds. Null until delivery finishes. */
|
||||
durationMs: integer("duration_ms"),
|
||||
/** Error message if `status === "failed"`. */
|
||||
error: text("error"),
|
||||
/** Raw JSON body of the inbound HTTP request. */
|
||||
payload: jsonb("payload").$type<Record<string, unknown>>().notNull(),
|
||||
/** Relevant HTTP headers from the inbound request (e.g. signature headers). */
|
||||
headers: jsonb("headers").$type<Record<string, string>>().notNull().default({}),
|
||||
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||
finishedAt: timestamp("finished_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pluginIdx: index("plugin_webhook_deliveries_plugin_idx").on(table.pluginId),
|
||||
statusIdx: index("plugin_webhook_deliveries_status_idx").on(table.status),
|
||||
keyIdx: index("plugin_webhook_deliveries_key_idx").on(table.webhookKey),
|
||||
}),
|
||||
);
|
||||
45
packages/db/src/schema/plugins.ts
Normal file
45
packages/db/src/schema/plugins.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
integer,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import type { PluginCategory, PluginStatus, PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* `plugins` table — stores one row per installed plugin.
|
||||
*
|
||||
* Each plugin is uniquely identified by `plugin_key` (derived from
|
||||
* the manifest `id`). The full manifest is persisted as JSONB in
|
||||
* `manifest_json` so the host can reconstruct capability and UI
|
||||
* slot information without loading the plugin package.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3
|
||||
*/
|
||||
export const plugins = pgTable(
|
||||
"plugins",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
pluginKey: text("plugin_key").notNull(),
|
||||
packageName: text("package_name").notNull(),
|
||||
version: text("version").notNull(),
|
||||
apiVersion: integer("api_version").notNull().default(1),
|
||||
categories: jsonb("categories").$type<PluginCategory[]>().notNull().default([]),
|
||||
manifestJson: jsonb("manifest_json").$type<PaperclipPluginManifestV1>().notNull(),
|
||||
status: text("status").$type<PluginStatus>().notNull().default("installed"),
|
||||
installOrder: integer("install_order"),
|
||||
/** Resolved package path for local-path installs; used to find worker entrypoint. */
|
||||
packagePath: text("package_path"),
|
||||
lastError: text("last_error"),
|
||||
installedAt: timestamp("installed_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pluginKeyIdx: uniqueIndex("plugins_plugin_key_idx").on(table.pluginKey),
|
||||
statusIdx: index("plugins_status_idx").on(table.status),
|
||||
}),
|
||||
);
|
||||
52
packages/plugins/create-paperclip-plugin/README.md
Normal file
52
packages/plugins/create-paperclip-plugin/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# @paperclipai/create-paperclip-plugin
|
||||
|
||||
Scaffolding tool for creating new Paperclip plugins.
|
||||
|
||||
```bash
|
||||
npx @paperclipai/create-paperclip-plugin my-plugin
|
||||
```
|
||||
|
||||
Or with options:
|
||||
|
||||
```bash
|
||||
npx @paperclipai/create-paperclip-plugin @acme/my-plugin \
|
||||
--template connector \
|
||||
--category connector \
|
||||
--display-name "Acme Connector" \
|
||||
--description "Syncs Acme data into Paperclip" \
|
||||
--author "Acme Inc"
|
||||
```
|
||||
|
||||
Supported templates: `default`, `connector`, `workspace`
|
||||
Supported categories: `connector`, `workspace`, `automation`, `ui`
|
||||
|
||||
Generates:
|
||||
- typed manifest + worker entrypoint
|
||||
- example UI widget using the supported `@paperclipai/plugin-sdk/ui` hooks
|
||||
- test file using `@paperclipai/plugin-sdk/testing`
|
||||
- `esbuild` and `rollup` config files using SDK bundler presets
|
||||
- dev server script for hot-reload (`paperclip-plugin-dev-server`)
|
||||
|
||||
The scaffold intentionally uses plain React elements rather than host-provided UI kit components, because the current plugin runtime does not ship a stable shared component library yet.
|
||||
|
||||
Inside this repo, the generated package uses `@paperclipai/plugin-sdk` via `workspace:*`.
|
||||
|
||||
Outside this repo, the scaffold snapshots `@paperclipai/plugin-sdk` from your local Paperclip checkout into a `.paperclip-sdk/` tarball and points the generated package at that local file by default. You can override the SDK source explicitly:
|
||||
|
||||
```bash
|
||||
node packages/plugins/create-paperclip-plugin/dist/index.js @acme/my-plugin \
|
||||
--output /absolute/path/to/plugins \
|
||||
--sdk-path /absolute/path/to/paperclip/packages/plugins/sdk
|
||||
```
|
||||
|
||||
That gives you an outside-repo local development path before the SDK is published to npm.
|
||||
|
||||
## Workflow after scaffolding
|
||||
|
||||
```bash
|
||||
cd my-plugin
|
||||
pnpm install
|
||||
pnpm dev # watch worker + manifest + ui bundles
|
||||
pnpm dev:ui # local UI preview server with hot-reload events
|
||||
pnpm test
|
||||
```
|
||||
40
packages/plugins/create-paperclip-plugin/package.json
Normal file
40
packages/plugins/create-paperclip-plugin/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@paperclipai/create-paperclip-plugin",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"create-paperclip-plugin": "./dist/index.js"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"bin": {
|
||||
"create-paperclip-plugin": "./dist/index.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
496
packages/plugins/create-paperclip-plugin/src/index.ts
Normal file
496
packages/plugins/create-paperclip-plugin/src/index.ts
Normal file
@@ -0,0 +1,496 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const VALID_TEMPLATES = ["default", "connector", "workspace"] as const;
|
||||
type PluginTemplate = (typeof VALID_TEMPLATES)[number];
|
||||
const VALID_CATEGORIES = new Set(["connector", "workspace", "automation", "ui"] as const);
|
||||
|
||||
export interface ScaffoldPluginOptions {
|
||||
pluginName: string;
|
||||
outputDir: string;
|
||||
template?: PluginTemplate;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
category?: "connector" | "workspace" | "automation" | "ui";
|
||||
sdkPath?: string;
|
||||
}
|
||||
|
||||
/** Validate npm-style plugin package names (scoped or unscoped). */
|
||||
export function isValidPluginName(name: string): boolean {
|
||||
const scopedPattern = /^@[a-z0-9_-]+\/[a-z0-9._-]+$/;
|
||||
const unscopedPattern = /^[a-z0-9._-]+$/;
|
||||
return scopedPattern.test(name) || unscopedPattern.test(name);
|
||||
}
|
||||
|
||||
/** Convert `@scope/name` to an output directory basename (`name`). */
|
||||
function packageToDirName(pluginName: string): string {
|
||||
return pluginName.replace(/^@[^/]+\//, "");
|
||||
}
|
||||
|
||||
/** Convert an npm package name into a manifest-safe plugin id. */
|
||||
function packageToManifestId(pluginName: string): string {
|
||||
if (!pluginName.startsWith("@")) {
|
||||
return pluginName;
|
||||
}
|
||||
|
||||
return pluginName.slice(1).replace("/", ".");
|
||||
}
|
||||
|
||||
/** Build a human-readable display name from package name tokens. */
|
||||
function makeDisplayName(pluginName: string): string {
|
||||
const raw = packageToDirName(pluginName).replace(/[._-]+/g, " ").trim();
|
||||
return raw
|
||||
.split(/\s+/)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function writeFile(target: string, content: string) {
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, content);
|
||||
}
|
||||
|
||||
function quote(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function toPosixPath(value: string): string {
|
||||
return value.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function formatFileDependency(absPath: string): string {
|
||||
return `file:${toPosixPath(path.resolve(absPath))}`;
|
||||
}
|
||||
|
||||
function getLocalSdkPackagePath(): string {
|
||||
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "sdk");
|
||||
}
|
||||
|
||||
function getRepoRootFromSdkPath(sdkPath: string): string {
|
||||
return path.resolve(sdkPath, "..", "..", "..");
|
||||
}
|
||||
|
||||
function getLocalSharedPackagePath(sdkPath: string): string {
|
||||
return path.resolve(getRepoRootFromSdkPath(sdkPath), "packages", "shared");
|
||||
}
|
||||
|
||||
function isInsideDir(targetPath: string, parentPath: string): boolean {
|
||||
const relative = path.relative(parentPath, targetPath);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function packLocalPackage(packagePath: string, outputDir: string): string {
|
||||
const packageJsonPath = path.join(packagePath, "package.json");
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
throw new Error(`Package package.json not found at ${packageJsonPath}`);
|
||||
}
|
||||
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
|
||||
name?: string;
|
||||
version?: string;
|
||||
};
|
||||
const packageName = packageJson.name ?? path.basename(packagePath);
|
||||
const packageVersion = packageJson.version ?? "0.0.0";
|
||||
const tarballFileName = `${packageName.replace(/^@/, "").replace("/", "-")}-${packageVersion}.tgz`;
|
||||
const sdkBundleDir = path.join(outputDir, ".paperclip-sdk");
|
||||
|
||||
fs.mkdirSync(sdkBundleDir, { recursive: true });
|
||||
execFileSync("pnpm", ["build"], { cwd: packagePath, stdio: "pipe" });
|
||||
execFileSync("pnpm", ["pack", "--pack-destination", sdkBundleDir], { cwd: packagePath, stdio: "pipe" });
|
||||
|
||||
const tarballPath = path.join(sdkBundleDir, tarballFileName);
|
||||
if (!fs.existsSync(tarballPath)) {
|
||||
throw new Error(`Packed tarball was not created at ${tarballPath}`);
|
||||
}
|
||||
|
||||
return tarballPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a complete Paperclip plugin starter project.
|
||||
*
|
||||
* Output includes manifest/worker/UI entries, SDK harness tests, bundler presets,
|
||||
* and a local dev server script for hot-reload workflow.
|
||||
*/
|
||||
export function scaffoldPluginProject(options: ScaffoldPluginOptions): string {
|
||||
const template = options.template ?? "default";
|
||||
if (!VALID_TEMPLATES.includes(template)) {
|
||||
throw new Error(`Invalid template '${template}'. Expected one of: ${VALID_TEMPLATES.join(", ")}`);
|
||||
}
|
||||
|
||||
if (!isValidPluginName(options.pluginName)) {
|
||||
throw new Error("Invalid plugin name. Must be lowercase and may include scope, dots, underscores, or hyphens.");
|
||||
}
|
||||
|
||||
if (options.category && !VALID_CATEGORIES.has(options.category)) {
|
||||
throw new Error(`Invalid category '${options.category}'. Expected one of: ${[...VALID_CATEGORIES].join(", ")}`);
|
||||
}
|
||||
|
||||
const outputDir = path.resolve(options.outputDir);
|
||||
if (fs.existsSync(outputDir)) {
|
||||
throw new Error(`Directory already exists: ${outputDir}`);
|
||||
}
|
||||
|
||||
const displayName = options.displayName ?? makeDisplayName(options.pluginName);
|
||||
const description = options.description ?? "A Paperclip plugin";
|
||||
const author = options.author ?? "Plugin Author";
|
||||
const category = options.category ?? (template === "workspace" ? "workspace" : "connector");
|
||||
const manifestId = packageToManifestId(options.pluginName);
|
||||
const localSdkPath = path.resolve(options.sdkPath ?? getLocalSdkPackagePath());
|
||||
const localSharedPath = getLocalSharedPackagePath(localSdkPath);
|
||||
const repoRoot = getRepoRootFromSdkPath(localSdkPath);
|
||||
const useWorkspaceSdk = isInsideDir(outputDir, repoRoot);
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const packedSharedTarball = useWorkspaceSdk ? null : packLocalPackage(localSharedPath, outputDir);
|
||||
const sdkDependency = useWorkspaceSdk
|
||||
? "workspace:*"
|
||||
: `file:${toPosixPath(path.relative(outputDir, packLocalPackage(localSdkPath, outputDir)))}`;
|
||||
|
||||
const packageJson = {
|
||||
name: options.pluginName,
|
||||
version: "0.1.0",
|
||||
type: "module",
|
||||
private: true,
|
||||
description,
|
||||
scripts: {
|
||||
build: "node ./esbuild.config.mjs",
|
||||
"build:rollup": "rollup -c",
|
||||
dev: "node ./esbuild.config.mjs --watch",
|
||||
"dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177",
|
||||
test: "vitest run --config ./vitest.config.ts",
|
||||
typecheck: "tsc --noEmit"
|
||||
},
|
||||
paperclipPlugin: {
|
||||
manifest: "./dist/manifest.js",
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui/"
|
||||
},
|
||||
keywords: ["paperclip", "plugin", category],
|
||||
author,
|
||||
license: "MIT",
|
||||
...(packedSharedTarball
|
||||
? {
|
||||
pnpm: {
|
||||
overrides: {
|
||||
"@paperclipai/shared": `file:${toPosixPath(path.relative(outputDir, packedSharedTarball))}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
devDependencies: {
|
||||
...(packedSharedTarball
|
||||
? {
|
||||
"@paperclipai/shared": `file:${toPosixPath(path.relative(outputDir, packedSharedTarball))}`,
|
||||
}
|
||||
: {}),
|
||||
"@paperclipai/plugin-sdk": sdkDependency,
|
||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||
"@rollup/plugin-typescript": "^12.1.2",
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^19.0.8",
|
||||
esbuild: "^0.27.3",
|
||||
rollup: "^4.38.0",
|
||||
tslib: "^2.8.1",
|
||||
typescript: "^5.7.3",
|
||||
vitest: "^3.0.5"
|
||||
},
|
||||
peerDependencies: {
|
||||
react: ">=18"
|
||||
}
|
||||
};
|
||||
|
||||
writeFile(path.join(outputDir, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
|
||||
const tsconfig = {
|
||||
compilerOptions: {
|
||||
target: "ES2022",
|
||||
module: "NodeNext",
|
||||
moduleResolution: "NodeNext",
|
||||
lib: ["ES2022", "DOM"],
|
||||
jsx: "react-jsx",
|
||||
strict: true,
|
||||
skipLibCheck: true,
|
||||
declaration: true,
|
||||
declarationMap: true,
|
||||
sourceMap: true,
|
||||
outDir: "dist",
|
||||
rootDir: "."
|
||||
},
|
||||
include: ["src", "tests"],
|
||||
exclude: ["dist", "node_modules"]
|
||||
};
|
||||
|
||||
writeFile(path.join(outputDir, "tsconfig.json"), `${JSON.stringify(tsconfig, null, 2)}\n`);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "esbuild.config.mjs"),
|
||||
`import esbuild from "esbuild";
|
||||
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
||||
|
||||
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
|
||||
const watch = process.argv.includes("--watch");
|
||||
|
||||
const workerCtx = await esbuild.context(presets.esbuild.worker);
|
||||
const manifestCtx = await esbuild.context(presets.esbuild.manifest);
|
||||
const uiCtx = await esbuild.context(presets.esbuild.ui);
|
||||
|
||||
if (watch) {
|
||||
await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]);
|
||||
console.log("esbuild watch mode enabled for worker, manifest, and ui");
|
||||
} else {
|
||||
await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]);
|
||||
await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]);
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "rollup.config.mjs"),
|
||||
`import { nodeResolve } from "@rollup/plugin-node-resolve";
|
||||
import typescript from "@rollup/plugin-typescript";
|
||||
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
||||
|
||||
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
|
||||
|
||||
function withPlugins(config) {
|
||||
if (!config) return null;
|
||||
return {
|
||||
...config,
|
||||
plugins: [
|
||||
nodeResolve({
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
|
||||
}),
|
||||
typescript({
|
||||
tsconfig: "./tsconfig.json",
|
||||
declaration: false,
|
||||
declarationMap: false,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export default [
|
||||
withPlugins(presets.rollup.manifest),
|
||||
withPlugins(presets.rollup.worker),
|
||||
withPlugins(presets.rollup.ui),
|
||||
].filter(Boolean);
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "vitest.config.ts"),
|
||||
`import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.spec.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "src", "manifest.ts"),
|
||||
`import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: ${quote(manifestId)},
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: ${quote(displayName)},
|
||||
description: ${quote(description)},
|
||||
author: ${quote(author)},
|
||||
categories: [${quote(category)}],
|
||||
capabilities: [
|
||||
"events.subscribe",
|
||||
"plugin.state.read",
|
||||
"plugin.state.write"
|
||||
],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui"
|
||||
},
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "dashboardWidget",
|
||||
id: "health-widget",
|
||||
displayName: ${quote(`${displayName} Health`)},
|
||||
exportName: "DashboardWidget"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "src", "worker.ts"),
|
||||
`import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.events.on("issue.created", async (event) => {
|
||||
const issueId = event.entityId ?? "unknown";
|
||||
await ctx.state.set({ scopeKind: "issue", scopeId: issueId, stateKey: "seen" }, true);
|
||||
ctx.logger.info("Observed issue.created", { issueId });
|
||||
});
|
||||
|
||||
ctx.data.register("health", async () => {
|
||||
return { status: "ok", checkedAt: new Date().toISOString() };
|
||||
});
|
||||
|
||||
ctx.actions.register("ping", async () => {
|
||||
ctx.logger.info("Ping action invoked");
|
||||
return { pong: true, at: new Date().toISOString() };
|
||||
});
|
||||
},
|
||||
|
||||
async onHealth() {
|
||||
return { status: "ok", message: "Plugin worker is running" };
|
||||
}
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "src", "ui", "index.tsx"),
|
||||
`import { usePluginAction, usePluginData, type PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
type HealthData = {
|
||||
status: "ok" | "degraded" | "error";
|
||||
checkedAt: string;
|
||||
};
|
||||
|
||||
export function DashboardWidget(_props: PluginWidgetProps) {
|
||||
const { data, loading, error } = usePluginData<HealthData>("health");
|
||||
const ping = usePluginAction("ping");
|
||||
|
||||
if (loading) return <div>Loading plugin health...</div>;
|
||||
if (error) return <div>Plugin error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: "0.5rem" }}>
|
||||
<strong>${displayName}</strong>
|
||||
<div>Health: {data?.status ?? "unknown"}</div>
|
||||
<div>Checked: {data?.checkedAt ?? "never"}</div>
|
||||
<button onClick={() => void ping()}>Ping Worker</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "tests", "plugin.spec.ts"),
|
||||
`import { describe, expect, it } from "vitest";
|
||||
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
|
||||
import manifest from "../src/manifest.js";
|
||||
import plugin from "../src/worker.js";
|
||||
|
||||
describe("plugin scaffold", () => {
|
||||
it("registers data + actions and handles events", async () => {
|
||||
const harness = createTestHarness({ manifest, capabilities: [...manifest.capabilities, "events.emit"] });
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
await harness.emit("issue.created", { issueId: "iss_1" }, { entityId: "iss_1", entityType: "issue" });
|
||||
expect(harness.getState({ scopeKind: "issue", scopeId: "iss_1", stateKey: "seen" })).toBe(true);
|
||||
|
||||
const data = await harness.getData<{ status: string }>("health");
|
||||
expect(data.status).toBe("ok");
|
||||
|
||||
const action = await harness.performAction<{ pong: boolean }>("ping");
|
||||
expect(action.pong).toBe(true);
|
||||
});
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "README.md"),
|
||||
`# ${displayName}
|
||||
|
||||
${description}
|
||||
|
||||
## Development
|
||||
|
||||
\`\`\`bash
|
||||
pnpm install
|
||||
pnpm dev # watch builds
|
||||
pnpm dev:ui # local dev server with hot-reload events
|
||||
pnpm test
|
||||
\`\`\`
|
||||
|
||||
${sdkDependency.startsWith("file:")
|
||||
? `This scaffold snapshots \`@paperclipai/plugin-sdk\` and \`@paperclipai/shared\` from a local Paperclip checkout at:\n\n\`${toPosixPath(localSdkPath)}\`\n\nThe packed tarballs live in \`.paperclip-sdk/\` for local development. Before publishing this plugin, switch those dependencies to published package versions once they are available on npm.\n\n`
|
||||
: ""}
|
||||
|
||||
## Install Into Paperclip
|
||||
|
||||
\`\`\`bash
|
||||
curl -X POST http://127.0.0.1:3100/api/plugins/install \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"packageName":"${toPosixPath(outputDir)}","isLocalPath":true}'
|
||||
\`\`\`
|
||||
|
||||
## Build Options
|
||||
|
||||
- \`pnpm build\` uses esbuild presets from \`@paperclipai/plugin-sdk/bundlers\`.
|
||||
- \`pnpm build:rollup\` uses rollup presets from the same SDK.
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(path.join(outputDir, ".gitignore"), "dist\nnode_modules\n.paperclip-sdk\n");
|
||||
|
||||
return outputDir;
|
||||
}
|
||||
|
||||
function parseArg(name: string): string | undefined {
|
||||
const index = process.argv.indexOf(name);
|
||||
if (index === -1) return undefined;
|
||||
return process.argv[index + 1];
|
||||
}
|
||||
|
||||
/** CLI wrapper for `scaffoldPluginProject`. */
|
||||
function runCli() {
|
||||
const pluginName = process.argv[2];
|
||||
if (!pluginName) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Usage: create-paperclip-plugin <name> [--template default|connector|workspace] [--output <dir>] [--sdk-path <paperclip-sdk-path>]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const template = (parseArg("--template") ?? "default") as PluginTemplate;
|
||||
const outputRoot = parseArg("--output") ?? process.cwd();
|
||||
const targetDir = path.resolve(outputRoot, packageToDirName(pluginName));
|
||||
|
||||
const out = scaffoldPluginProject({
|
||||
pluginName,
|
||||
outputDir: targetDir,
|
||||
template,
|
||||
displayName: parseArg("--display-name"),
|
||||
description: parseArg("--description"),
|
||||
author: parseArg("--author"),
|
||||
category: parseArg("--category") as ScaffoldPluginOptions["category"] | undefined,
|
||||
sdkPath: parseArg("--sdk-path"),
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Created plugin scaffold at ${out}`);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
runCli();
|
||||
}
|
||||
9
packages/plugins/create-paperclip-plugin/tsconfig.json
Normal file
9
packages/plugins/create-paperclip-plugin/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
2
packages/plugins/examples/plugin-authoring-smoke-example/.gitignore
vendored
Normal file
2
packages/plugins/examples/plugin-authoring-smoke-example/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
dist
|
||||
node_modules
|
||||
@@ -0,0 +1,23 @@
|
||||
# Plugin Authoring Smoke Example
|
||||
|
||||
A Paperclip plugin
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # watch builds
|
||||
pnpm dev:ui # local dev server with hot-reload events
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## Install Into Paperclip
|
||||
|
||||
```bash
|
||||
pnpm paperclipai plugin install ./
|
||||
```
|
||||
|
||||
## Build Options
|
||||
|
||||
- `pnpm build` uses esbuild presets from `@paperclipai/plugin-sdk/bundlers`.
|
||||
- `pnpm build:rollup` uses rollup presets from the same SDK.
|
||||
@@ -0,0 +1,17 @@
|
||||
import esbuild from "esbuild";
|
||||
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
||||
|
||||
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
|
||||
const watch = process.argv.includes("--watch");
|
||||
|
||||
const workerCtx = await esbuild.context(presets.esbuild.worker);
|
||||
const manifestCtx = await esbuild.context(presets.esbuild.manifest);
|
||||
const uiCtx = await esbuild.context(presets.esbuild.ui);
|
||||
|
||||
if (watch) {
|
||||
await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]);
|
||||
console.log("esbuild watch mode enabled for worker, manifest, and ui");
|
||||
} else {
|
||||
await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]);
|
||||
await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-authoring-smoke-example",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"description": "A Paperclip plugin",
|
||||
"scripts": {
|
||||
"prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs",
|
||||
"build": "node ./esbuild.config.mjs",
|
||||
"build:rollup": "rollup -c",
|
||||
"dev": "node ./esbuild.config.mjs --watch",
|
||||
"dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177",
|
||||
"test": "vitest run --config ./vitest.config.ts",
|
||||
"typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
|
||||
},
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js",
|
||||
"ui": "./dist/ui/"
|
||||
},
|
||||
"keywords": [
|
||||
"paperclip",
|
||||
"plugin",
|
||||
"connector"
|
||||
],
|
||||
"author": "Plugin Author",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@paperclipai/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||
"@rollup/plugin-typescript": "^12.1.2",
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"esbuild": "^0.27.3",
|
||||
"rollup": "^4.38.0",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { nodeResolve } from "@rollup/plugin-node-resolve";
|
||||
import typescript from "@rollup/plugin-typescript";
|
||||
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
||||
|
||||
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
|
||||
|
||||
function withPlugins(config) {
|
||||
if (!config) return null;
|
||||
return {
|
||||
...config,
|
||||
plugins: [
|
||||
nodeResolve({
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
|
||||
}),
|
||||
typescript({
|
||||
tsconfig: "./tsconfig.json",
|
||||
declaration: false,
|
||||
declarationMap: false,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export default [
|
||||
withPlugins(presets.rollup.manifest),
|
||||
withPlugins(presets.rollup.worker),
|
||||
withPlugins(presets.rollup.ui),
|
||||
].filter(Boolean);
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: "paperclipai.plugin-authoring-smoke-example",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Plugin Authoring Smoke Example",
|
||||
description: "A Paperclip plugin",
|
||||
author: "Plugin Author",
|
||||
categories: ["connector"],
|
||||
capabilities: [
|
||||
"events.subscribe",
|
||||
"plugin.state.read",
|
||||
"plugin.state.write"
|
||||
],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui"
|
||||
},
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "dashboardWidget",
|
||||
id: "health-widget",
|
||||
displayName: "Plugin Authoring Smoke Example Health",
|
||||
exportName: "DashboardWidget"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { usePluginAction, usePluginData, type PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
type HealthData = {
|
||||
status: "ok" | "degraded" | "error";
|
||||
checkedAt: string;
|
||||
};
|
||||
|
||||
export function DashboardWidget(_props: PluginWidgetProps) {
|
||||
const { data, loading, error } = usePluginData<HealthData>("health");
|
||||
const ping = usePluginAction("ping");
|
||||
|
||||
if (loading) return <div>Loading plugin health...</div>;
|
||||
if (error) return <div>Plugin error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: "0.5rem" }}>
|
||||
<strong>Plugin Authoring Smoke Example</strong>
|
||||
<div>Health: {data?.status ?? "unknown"}</div>
|
||||
<div>Checked: {data?.checkedAt ?? "never"}</div>
|
||||
<button onClick={() => void ping()}>Ping Worker</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.events.on("issue.created", async (event) => {
|
||||
const issueId = event.entityId ?? "unknown";
|
||||
await ctx.state.set({ scopeKind: "issue", scopeId: issueId, stateKey: "seen" }, true);
|
||||
ctx.logger.info("Observed issue.created", { issueId });
|
||||
});
|
||||
|
||||
ctx.data.register("health", async () => {
|
||||
return { status: "ok", checkedAt: new Date().toISOString() };
|
||||
});
|
||||
|
||||
ctx.actions.register("ping", async () => {
|
||||
ctx.logger.info("Ping action invoked");
|
||||
return { pong: true, at: new Date().toISOString() };
|
||||
});
|
||||
},
|
||||
|
||||
async onHealth() {
|
||||
return { status: "ok", message: "Plugin worker is running" };
|
||||
}
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
|
||||
import manifest from "../src/manifest.js";
|
||||
import plugin from "../src/worker.js";
|
||||
|
||||
describe("plugin scaffold", () => {
|
||||
it("registers data + actions and handles events", async () => {
|
||||
const harness = createTestHarness({ manifest, capabilities: [...manifest.capabilities, "events.emit"] });
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
await harness.emit("issue.created", { issueId: "iss_1" }, { entityId: "iss_1", entityType: "issue" });
|
||||
expect(harness.getState({ scopeKind: "issue", scopeId: "iss_1", stateKey: "seen" })).toBe(true);
|
||||
|
||||
const data = await harness.getData<{ status: string }>("health");
|
||||
expect(data.status).toBe("ok");
|
||||
|
||||
const action = await harness.performAction<{ pong: boolean }>("ping");
|
||||
expect(action.pong).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"tests"
|
||||
],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.spec.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
# File Browser Example Plugin
|
||||
|
||||
Example Paperclip plugin that demonstrates:
|
||||
|
||||
- **projectSidebarItem** — An optional "Files" link under each project in the sidebar that opens the project detail with this plugin’s tab selected. This is controlled by plugin settings and defaults to off.
|
||||
- **detailTab** (entityType project) — A project detail tab with a workspace-path selector, a desktop two-column layout (file tree left, editor right), and a mobile one-panel flow with a back button from editor to file tree, including save support.
|
||||
|
||||
This is a repo-local example plugin for development. It should not be assumed to ship in a generic production build unless it is explicitly included.
|
||||
|
||||
## Slots
|
||||
|
||||
| Slot | Type | Description |
|
||||
|---------------------|---------------------|--------------------------------------------------|
|
||||
| Files (sidebar) | `projectSidebarItem`| Optional link under each project → project detail + tab. |
|
||||
| Files (tab) | `detailTab` | Responsive tree/editor layout with save support.|
|
||||
|
||||
## Settings
|
||||
|
||||
- `Show Files in Sidebar` — toggles the project sidebar link on or off. Defaults to off.
|
||||
- `Comment File Links` — controls whether comment annotations and the comment context-menu action are shown.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- `ui.sidebar.register` — project sidebar item
|
||||
- `ui.detailTab.register` — project detail tab
|
||||
- `projects.read` — resolve project
|
||||
- `project.workspaces.read` — list workspaces and read paths for file access
|
||||
|
||||
## Worker
|
||||
|
||||
- **getData `workspaces`** — `ctx.projects.listWorkspaces(projectId, companyId)` (ordered, primary first).
|
||||
- **getData `fileList`** — `{ projectId, workspaceId, directoryPath? }` → list directory entries for the workspace root or a subdirectory (Node `fs`).
|
||||
- **getData `fileContent`** — `{ projectId, workspaceId, filePath }` → read file content using workspace-relative paths (Node `fs`).
|
||||
- **performAction `writeFile`** — `{ projectId, workspaceId, filePath, content }` → write the current editor buffer back to disk.
|
||||
|
||||
## Local Install (Dev)
|
||||
|
||||
From the repo root, build the plugin and install it by local path:
|
||||
|
||||
```bash
|
||||
pnpm --filter @paperclipai/plugin-file-browser-example build
|
||||
pnpm paperclipai plugin install ./packages/plugins/examples/plugin-file-browser-example
|
||||
```
|
||||
|
||||
To uninstall:
|
||||
|
||||
```bash
|
||||
pnpm paperclipai plugin uninstall paperclip-file-browser-example --force
|
||||
```
|
||||
|
||||
**Local development notes:**
|
||||
|
||||
- **Build first.** The host resolves the worker from the manifest `entrypoints.worker` (e.g. `./dist/worker.js`). Run `pnpm build` in the plugin directory before installing so the worker file exists.
|
||||
- **Dev-only install path.** This local-path install flow assumes this monorepo checkout is present on disk. For deployed installs, publish an npm package instead of depending on `packages/plugins/examples/...` existing on the host.
|
||||
- **Reinstall after pulling.** If you installed a plugin by local path before the server stored `package_path`, the plugin may show status **error** (worker not found). Uninstall and install again so the server persists the path and can activate the plugin.
|
||||
- Optional: use `paperclip-plugin-dev-server` for UI hot-reload with `devUiUrl` in plugin config.
|
||||
|
||||
## Structure
|
||||
|
||||
- `src/manifest.ts` — manifest with `projectSidebarItem` and `detailTab` (entityTypes `["project"]`).
|
||||
- `src/worker.ts` — data handlers for workspaces, file list, file content.
|
||||
- `src/ui/index.tsx` — `FilesLink` (sidebar) and `FilesTab` (workspace path selector + two-panel file tree/editor).
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-file-browser-example",
|
||||
"version": "0.1.0",
|
||||
"description": "Example plugin: project sidebar Files link + project detail tab with workspace selector and file browser",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js",
|
||||
"ui": "./dist/ui/"
|
||||
},
|
||||
"scripts": {
|
||||
"prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs",
|
||||
"build": "tsc && node ./scripts/build-ui.mjs",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-javascript": "^6.2.2",
|
||||
"@codemirror/language": "^6.11.0",
|
||||
"@codemirror/state": "^6.4.0",
|
||||
"@codemirror/view": "^6.28.0",
|
||||
"@lezer/highlight": "^1.2.1",
|
||||
"@paperclipai/plugin-sdk": "workspace:*",
|
||||
"codemirror": "^6.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"esbuild": "^0.27.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import esbuild from "esbuild";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const packageRoot = path.resolve(__dirname, "..");
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [path.join(packageRoot, "src/ui/index.tsx")],
|
||||
outfile: path.join(packageRoot, "dist/ui/index.js"),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "browser",
|
||||
target: ["es2022"],
|
||||
sourcemap: true,
|
||||
external: [
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
"@paperclipai/plugin-sdk/ui",
|
||||
],
|
||||
logLevel: "info",
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user