Compare commits
16 Commits
1ac85d837a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcb0846ab8 | ||
|
|
035cb8aec2 | ||
|
|
b1c4b2e420 | ||
|
|
1d1511e37c | ||
|
|
fff0600b1d | ||
|
|
1cd61601f3 | ||
|
|
6eb9545a72 | ||
|
|
47a6d86174 | ||
|
|
aa854e7efe | ||
|
|
5536e6b91e | ||
|
|
f37e0aa7b3 | ||
|
|
c539fcde8b | ||
|
|
7a08fbd370 | ||
|
|
71e1bc260d | ||
|
|
3d2abbde72 | ||
|
|
ff02220890 |
15
.env.example
15
.env.example
@@ -1,3 +1,14 @@
|
|||||||
DATABASE_URL=postgres://paperclip:paperclip@localhost:5432/paperclip
|
# === Dev local (không cần DATABASE_URL, server dùng embedded PGlite) ===
|
||||||
|
# DATABASE_URL=postgres://paperclip:paperclip@localhost:5432/paperclip
|
||||||
|
|
||||||
|
# Server
|
||||||
PORT=3100
|
PORT=3100
|
||||||
SERVE_UI=false
|
SERVE_UI=true
|
||||||
|
|
||||||
|
# Auth (bắt buộc cho authenticated mode)
|
||||||
|
PAPERCLIP_PUBLIC_URL=http://localhost:3100
|
||||||
|
BETTER_AUTH_SECRET=dev-secret-change-me-please-replace
|
||||||
|
|
||||||
|
# Agent API keys (optional)
|
||||||
|
# OPENAI_API_KEY=sk-...
|
||||||
|
# ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,6 +11,8 @@ data/
|
|||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
tmp-*
|
tmp-*
|
||||||
cli/tmp/
|
cli/tmp/
|
||||||
|
package-lock.json
|
||||||
|
VissSoft_CEO/
|
||||||
|
|
||||||
# Scratch/seed scripts (but not scripts/ dir)
|
# Scratch/seed scripts (but not scripts/ dir)
|
||||||
check-*.mjs
|
check-*.mjs
|
||||||
|
|||||||
@@ -15,6 +15,30 @@ Current implementation status:
|
|||||||
- Node.js 20+
|
- Node.js 20+
|
||||||
- pnpm 9+
|
- pnpm 9+
|
||||||
|
|
||||||
|
## Windows Development Notes
|
||||||
|
|
||||||
|
The project runs on Windows with a few adjustments:
|
||||||
|
|
||||||
|
**Install pnpm via corepack (bundled with Node.js 20+):**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
corepack enable
|
||||||
|
corepack prepare pnpm@9.15.4 --activate
|
||||||
|
```
|
||||||
|
|
||||||
|
**Shell command equivalents:**
|
||||||
|
|
||||||
|
| Linux/macOS | Windows (PowerShell) |
|
||||||
|
|---|---|
|
||||||
|
| `rm -rf ~/.paperclip/instances/default/db` | `Remove-Item -Recurse -Force "$env:USERPROFILE\.paperclip\instances\default\db"` |
|
||||||
|
| `export DATABASE_URL=...` | `$env:DATABASE_URL = "..."` |
|
||||||
|
| `curl http://localhost:3100/api/health` | `Invoke-RestMethod http://localhost:3100/api/health` |
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Shell scripts (`.sh`) under `scripts/` require Git Bash or WSL. Most common dev commands (`pnpm dev`, `pnpm build`, `pnpm typecheck`, `pnpm test:run`) work natively in PowerShell.
|
||||||
|
- Data paths use `%USERPROFILE%\.paperclip\` instead of `~/.paperclip/`.
|
||||||
|
- If using Docker Desktop for Windows, ensure WSL 2 backend is enabled.
|
||||||
|
|
||||||
## Dependency Lockfile Policy
|
## Dependency Lockfile Policy
|
||||||
|
|
||||||
GitHub Actions owns `pnpm-lock.yaml`.
|
GitHub Actions owns `pnpm-lock.yaml`.
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ export type TranscriptEntry =
|
|||||||
| { kind: "thinking"; ts: string; text: string; delta?: boolean }
|
| { kind: "thinking"; ts: string; text: string; delta?: boolean }
|
||||||
| { kind: "user"; ts: string; text: string }
|
| { kind: "user"; ts: string; text: string }
|
||||||
| { kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string }
|
| { kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string }
|
||||||
| { kind: "tool_result"; ts: string; toolUseId: string; content: string; isError: boolean }
|
| { kind: "tool_result"; ts: string; toolUseId: string; toolName?: string; content: string; isError: boolean }
|
||||||
| { kind: "init"; ts: string; model: string; sessionId: string }
|
| { kind: "init"; ts: string; model: string; sessionId: string }
|
||||||
| { kind: "result"; ts: string; text: string; inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number; subtype: string; isError: boolean; errors: string[] }
|
| { kind: "result"; ts: string; text: string; inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number; subtype: string; isError: boolean; errors: string[] }
|
||||||
| { kind: "stderr"; ts: string; text: string }
|
| { kind: "stderr"; ts: string; text: string }
|
||||||
|
|||||||
@@ -605,6 +605,7 @@ class GatewayWsClient {
|
|||||||
this.resolveChallenge = resolve;
|
this.resolveChallenge = resolve;
|
||||||
this.rejectChallenge = reject;
|
this.rejectChallenge = reject;
|
||||||
});
|
});
|
||||||
|
this.challengePromise.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(
|
async connect(
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { ensurePiModelConfiguredAndAvailable } from "./models.js";
|
|||||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
const PAPERCLIP_SESSIONS_DIR = path.join(os.homedir(), ".pi", "paperclips");
|
const PAPERCLIP_SESSIONS_DIR = path.join(os.homedir(), ".pi", "paperclips");
|
||||||
|
const PI_AGENT_SKILLS_DIR = path.join(os.homedir(), ".pi", "agent", "skills");
|
||||||
|
|
||||||
function firstNonEmptyLine(text: string): string {
|
function firstNonEmptyLine(text: string): string {
|
||||||
return (
|
return (
|
||||||
@@ -56,35 +57,35 @@ function resolvePiBiller(env: Record<string, string>, provider: string | null):
|
|||||||
|
|
||||||
async function ensurePiSkillsInjected(onLog: AdapterExecutionContext["onLog"]) {
|
async function ensurePiSkillsInjected(onLog: AdapterExecutionContext["onLog"]) {
|
||||||
const skillsEntries = await listPaperclipSkillEntries(__moduleDir);
|
const skillsEntries = await listPaperclipSkillEntries(__moduleDir);
|
||||||
|
|
||||||
|
await fs.mkdir(PI_AGENT_SKILLS_DIR, { recursive: true });
|
||||||
if (skillsEntries.length === 0) return;
|
if (skillsEntries.length === 0) return;
|
||||||
|
|
||||||
const piSkillsHome = path.join(os.homedir(), ".pi", "agent", "skills");
|
|
||||||
await fs.mkdir(piSkillsHome, { recursive: true });
|
|
||||||
const removedSkills = await removeMaintainerOnlySkillSymlinks(
|
const removedSkills = await removeMaintainerOnlySkillSymlinks(
|
||||||
piSkillsHome,
|
PI_AGENT_SKILLS_DIR,
|
||||||
skillsEntries.map((entry) => entry.name),
|
skillsEntries.map((entry) => entry.name),
|
||||||
);
|
);
|
||||||
for (const skillName of removedSkills) {
|
for (const skillName of removedSkills) {
|
||||||
await onLog(
|
await onLog(
|
||||||
"stderr",
|
"stderr",
|
||||||
`[paperclip] Removed maintainer-only Pi skill "${skillName}" from ${piSkillsHome}\n`,
|
`[paperclip] Removed maintainer-only Pi skill "${skillName}" from ${PI_AGENT_SKILLS_DIR}\n`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of skillsEntries) {
|
for (const entry of skillsEntries) {
|
||||||
const target = path.join(piSkillsHome, entry.name);
|
const target = path.join(PI_AGENT_SKILLS_DIR, entry.name);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await ensurePaperclipSkillSymlink(entry.source, target);
|
const result = await ensurePaperclipSkillSymlink(entry.source, target);
|
||||||
if (result === "skipped") continue;
|
if (result === "skipped") continue;
|
||||||
await onLog(
|
await onLog(
|
||||||
"stderr",
|
"stderr",
|
||||||
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Pi skill "${entry.name}" into ${piSkillsHome}\n`,
|
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Pi skill "${entry.name}" into ${PI_AGENT_SKILLS_DIR}\n`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await onLog(
|
await onLog(
|
||||||
"stderr",
|
"stderr",
|
||||||
`[paperclip] Failed to inject Pi skill "${entry.name}" into ${piSkillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
`[paperclip] Failed to inject Pi skill "${entry.name}" into ${PI_AGENT_SKILLS_DIR}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -336,6 +337,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
args.push("--tools", "read,bash,edit,write,grep,find,ls");
|
args.push("--tools", "read,bash,edit,write,grep,find,ls");
|
||||||
args.push("--session", sessionFile);
|
args.push("--session", sessionFile);
|
||||||
|
|
||||||
|
// Add Paperclip skills directory so Pi can load the paperclip skill
|
||||||
|
args.push("--skill", PI_AGENT_SKILLS_DIR);
|
||||||
|
|
||||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||||
|
|
||||||
return args;
|
return args;
|
||||||
|
|||||||
@@ -72,11 +72,22 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
|||||||
for (const tr of toolResults) {
|
for (const tr of toolResults) {
|
||||||
const content = tr.content;
|
const content = tr.content;
|
||||||
const isError = tr.isError === true;
|
const isError = tr.isError === true;
|
||||||
const contentStr = typeof content === "string" ? content : JSON.stringify(content);
|
|
||||||
|
// Extract text from Pi's content array format
|
||||||
|
let contentStr: string;
|
||||||
|
if (typeof content === "string") {
|
||||||
|
contentStr = content;
|
||||||
|
} else if (Array.isArray(content)) {
|
||||||
|
contentStr = extractTextContent(content as Array<{ type: string; text?: string }>);
|
||||||
|
} else {
|
||||||
|
contentStr = JSON.stringify(content);
|
||||||
|
}
|
||||||
|
|
||||||
entries.push({
|
entries.push({
|
||||||
kind: "tool_result",
|
kind: "tool_result",
|
||||||
ts,
|
ts,
|
||||||
toolUseId: asString(tr.toolCallId, "unknown"),
|
toolUseId: asString(tr.toolCallId, "unknown"),
|
||||||
|
toolName: asString(tr.toolName),
|
||||||
content: contentStr,
|
content: contentStr,
|
||||||
isError,
|
isError,
|
||||||
});
|
});
|
||||||
@@ -130,14 +141,35 @@ export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
|||||||
|
|
||||||
if (type === "tool_execution_end") {
|
if (type === "tool_execution_end") {
|
||||||
const toolCallId = asString(parsed.toolCallId);
|
const toolCallId = asString(parsed.toolCallId);
|
||||||
|
const toolName = asString(parsed.toolName);
|
||||||
const result = parsed.result;
|
const result = parsed.result;
|
||||||
const isError = parsed.isError === true;
|
const isError = parsed.isError === true;
|
||||||
const contentStr = typeof result === "string" ? result : JSON.stringify(result);
|
|
||||||
|
// Extract text from Pi's content array format
|
||||||
|
// Can be: {"content": [{"type": "text", "text": "..."}]} or [{"type": "text", "text": "..."}]
|
||||||
|
let contentStr: string;
|
||||||
|
if (typeof result === "string") {
|
||||||
|
contentStr = result;
|
||||||
|
} else if (Array.isArray(result)) {
|
||||||
|
// Direct array format: result is [{"type": "text", "text": "..."}]
|
||||||
|
contentStr = extractTextContent(result as Array<{ type: string; text?: string }>);
|
||||||
|
} else if (result && typeof result === "object") {
|
||||||
|
const resultObj = result as Record<string, unknown>;
|
||||||
|
if (Array.isArray(resultObj.content)) {
|
||||||
|
// Wrapped format: result is {"content": [{"type": "text", "text": "..."}]}
|
||||||
|
contentStr = extractTextContent(resultObj.content as Array<{ type: string; text?: string }>);
|
||||||
|
} else {
|
||||||
|
contentStr = JSON.stringify(result);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
contentStr = JSON.stringify(result);
|
||||||
|
}
|
||||||
|
|
||||||
return [{
|
return [{
|
||||||
kind: "tool_result",
|
kind: "tool_result",
|
||||||
ts,
|
ts,
|
||||||
toolUseId: toolCallId || "unknown",
|
toolUseId: toolCallId || "unknown",
|
||||||
|
toolName,
|
||||||
content: contentStr,
|
content: contentStr,
|
||||||
isError,
|
isError,
|
||||||
}];
|
}];
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ Read enough ancestor/comment context to understand _why_ the task exists and wha
|
|||||||
**Step 8 — Update status and communicate.** Always include the run ID header.
|
**Step 8 — Update status and communicate.** Always include the run ID header.
|
||||||
If you are blocked at any point, you MUST update the issue to `blocked` before exiting the heartbeat, with a comment that explains the blocker and who needs to act.
|
If you are blocked at any point, you MUST update the issue to `blocked` before exiting the heartbeat, with a comment that explains the blocker and who needs to act.
|
||||||
|
|
||||||
|
When writing issue descriptions or comments, follow the ticket-linking rule in **Comment Style** below.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
PATCH /api/issues/{issueId}
|
PATCH /api/issues/{issueId}
|
||||||
Headers: X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID
|
Headers: X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID
|
||||||
@@ -144,12 +146,19 @@ Access control:
|
|||||||
|
|
||||||
## Comment Style (Required)
|
## Comment Style (Required)
|
||||||
|
|
||||||
When posting issue comments, use concise markdown with:
|
When posting issue comments or writing issue descriptions, use concise markdown with:
|
||||||
|
|
||||||
- a short status line
|
- a short status line
|
||||||
- bullets for what changed / what is blocked
|
- bullets for what changed / what is blocked
|
||||||
- links to related entities when available
|
- links to related entities when available
|
||||||
|
|
||||||
|
**Ticket references are links (required):** If you mention another issue identifier such as `PAP-224`, `ZED-24`, or any `{PREFIX}-{NUMBER}` ticket id inside a comment body or issue description, wrap it in a Markdown link:
|
||||||
|
|
||||||
|
- `[PAP-224](/PAP/issues/PAP-224)`
|
||||||
|
- `[ZED-24](/ZED/issues/ZED-24)`
|
||||||
|
|
||||||
|
Never leave bare ticket ids in issue descriptions or comments when a clickable internal link can be provided.
|
||||||
|
|
||||||
**Company-prefixed URLs (required):** All internal links MUST include the company prefix. Derive the prefix from any issue identifier you have (e.g., `PAP-315` → prefix is `PAP`). Use this prefix in all UI links:
|
**Company-prefixed URLs (required):** All internal links MUST include the company prefix. Derive the prefix from any issue identifier you have (e.g., `PAP-315` → prefix is `PAP`). Use this prefix in all UI links:
|
||||||
|
|
||||||
- Issues: `/<prefix>/issues/<issue-identifier>` (e.g., `/PAP/issues/PAP-224`)
|
- Issues: `/<prefix>/issues/<issue-identifier>` (e.g., `/PAP/issues/PAP-224`)
|
||||||
@@ -171,7 +180,8 @@ Submitted CTO hire request and linked it for board review.
|
|||||||
|
|
||||||
- Approval: [ca6ba09d](/PAP/approvals/ca6ba09d-b558-4a53-a552-e7ef87e54a1b)
|
- Approval: [ca6ba09d](/PAP/approvals/ca6ba09d-b558-4a53-a552-e7ef87e54a1b)
|
||||||
- Pending agent: [CTO draft](/PAP/agents/cto)
|
- Pending agent: [CTO draft](/PAP/agents/cto)
|
||||||
- Source issue: [PC-142](/PAP/issues/PC-142)
|
- Source issue: [PAP-142](/PAP/issues/PAP-142)
|
||||||
|
- Depends on: [PAP-224](/PAP/issues/PAP-224)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Planning (Required when planning requested)
|
## Planning (Required when planning requested)
|
||||||
|
|||||||
@@ -939,7 +939,7 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
|
|||||||
|
|
||||||
/* ---- Internal sub-components ---- */
|
/* ---- Internal sub-components ---- */
|
||||||
|
|
||||||
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor"]);
|
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor"]);
|
||||||
|
|
||||||
/** Display list includes all real adapter types plus UI-only coming-soon entries. */
|
/** Display list includes all real adapter types plus UI-only coming-soon entries. */
|
||||||
const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [
|
const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [
|
||||||
|
|||||||
@@ -400,7 +400,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
|||||||
type: "tool",
|
type: "tool",
|
||||||
ts: entry.ts,
|
ts: entry.ts,
|
||||||
endTs: entry.ts,
|
endTs: entry.ts,
|
||||||
name: "tool",
|
name: entry.toolName ?? "tool",
|
||||||
toolUseId: entry.toolUseId,
|
toolUseId: entry.toolUseId,
|
||||||
input: null,
|
input: null,
|
||||||
result: entry.content,
|
result: entry.content,
|
||||||
|
|||||||
Reference in New Issue
Block a user