diff --git a/.gitignore b/.gitignore index f2c9b9a7..312c3969 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ tmp/ tests/e2e/test-results/ tests/e2e/playwright-report/ .superset/ +.claude/worktrees/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ab420a24..1eba95e3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,7 @@ We really appreciate both small fixes and thoughtful larger changes. ## Two Paths to Get Your Pull Request Accepted ### Path 1: Small, Focused Changes (Fastest way to get merged) + - Pick **one** clear thing to fix/improve - Touch the **smallest possible number of files** - Make sure the change is very targeted and easy to review @@ -16,6 +17,7 @@ We really appreciate both small fixes and thoughtful larger changes. These almost always get merged quickly when they're clean. ### Path 2: Bigger or Impactful Changes + - **First** talk about it in Discord → #dev channel → Describe what you're trying to solve → Share rough ideas / approach @@ -30,12 +32,43 @@ These almost always get merged quickly when they're clean. PRs that follow this path are **much** more likely to be accepted, even when they're large. ## General Rules (both paths) + - Write clear commit messages - Keep PR title + description meaningful - One PR = one logical change (unless it's a small related group) - Run tests locally first - Be kind in discussions 😄 +## Writing a Good PR message + +Please include a "thinking path" at the top of your PR message that explains from the top of the project down to what you fixed. E.g.: + +### Thinking Path Example 1: + +> - Paperclip orchestrates ai-agents for zero-human companies +> - There are many types of adapters for each LLM model provider +> - But LLM's have a context limit and not all agents can automatically compact their context +> - So we need to have an adapter-specific configuration for which adapters can and cannot automatically compact their context +> - This pull request adds per-adapter configuration of compaction, either auto or paperclip managed +> - That way we can get optimal performance from any adapter/provider in Paperclip + +### Thinking Path Example 2: + +> - Paperclip orchestrates ai-agents for zero-human companies +> - But humans want to watch the agents and oversee their work +> - Human users also operate in teams and so they need their own logins, profiles, views etc. +> - So we have a multi-user system for humans +> - But humans want to be able to update their own profile picture and avatar +> - But the avatar upload form wasn't saving the avatar to the file storage system +> - So this PR fixes the avatar upload form to use the file storage service +> - The benefit is we don't have a one-off file storage for just one aspect of the system, which would cause confusion and extra configuration + +Then have the rest of your normal PR message after the Thinking Path. + +This should include details about what you did, why you did it, why it matters & the benefits, how we can verify it works, and any risks. + +Please include screenshots if possible if you have a visible change. (use something like the [agent-browser skill](https://github.com/vercel-labs/agent-browser/blob/main/skills/agent-browser/SKILL.md) or similar to take screenshots). Ideally, you include before and after screenshots. + Questions? Just ask in #dev — we're happy to help. Happy hacking! diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index cc3cd7e0..103cb68e 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -24,6 +24,20 @@ export type { CLIAdapterModule, CreateConfigValues, } from "./types.js"; +export type { + SessionCompactionPolicy, + NativeContextManagement, + AdapterSessionManagement, + ResolvedSessionCompactionPolicy, +} from "./session-compaction.js"; +export { + ADAPTER_SESSION_MANAGEMENT, + LEGACY_SESSIONED_ADAPTER_TYPES, + getAdapterSessionManagement, + readSessionCompactionOverride, + resolveSessionCompactionPolicy, + hasSessionCompactionThresholds, +} from "./session-compaction.js"; export { REDACTED_HOME_PATH_USER, redactHomePathUserSegments, diff --git a/packages/adapter-utils/src/session-compaction.ts b/packages/adapter-utils/src/session-compaction.ts new file mode 100644 index 00000000..308b54a3 --- /dev/null +++ b/packages/adapter-utils/src/session-compaction.ts @@ -0,0 +1,175 @@ +export interface SessionCompactionPolicy { + enabled: boolean; + maxSessionRuns: number; + maxRawInputTokens: number; + maxSessionAgeHours: number; +} + +export type NativeContextManagement = "confirmed" | "likely" | "unknown" | "none"; + +export interface AdapterSessionManagement { + supportsSessionResume: boolean; + nativeContextManagement: NativeContextManagement; + defaultSessionCompaction: SessionCompactionPolicy; +} + +export interface ResolvedSessionCompactionPolicy { + policy: SessionCompactionPolicy; + adapterSessionManagement: AdapterSessionManagement | null; + explicitOverride: Partial; + source: "adapter_default" | "agent_override" | "legacy_fallback"; +} + +const DEFAULT_SESSION_COMPACTION_POLICY: SessionCompactionPolicy = { + enabled: true, + maxSessionRuns: 200, + maxRawInputTokens: 2_000_000, + maxSessionAgeHours: 72, +}; + +// Adapters with native context management still participate in session resume, +// but Paperclip should not rotate them using threshold-based compaction. +const ADAPTER_MANAGED_SESSION_POLICY: SessionCompactionPolicy = { + enabled: true, + maxSessionRuns: 0, + maxRawInputTokens: 0, + maxSessionAgeHours: 0, +}; + +export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([ + "claude_local", + "codex_local", + "cursor", + "gemini_local", + "opencode_local", + "pi_local", +]); + +export const ADAPTER_SESSION_MANAGEMENT: Record = { + claude_local: { + supportsSessionResume: true, + nativeContextManagement: "confirmed", + defaultSessionCompaction: ADAPTER_MANAGED_SESSION_POLICY, + }, + codex_local: { + supportsSessionResume: true, + nativeContextManagement: "confirmed", + defaultSessionCompaction: ADAPTER_MANAGED_SESSION_POLICY, + }, + cursor: { + supportsSessionResume: true, + nativeContextManagement: "unknown", + defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY, + }, + gemini_local: { + supportsSessionResume: true, + nativeContextManagement: "unknown", + defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY, + }, + opencode_local: { + supportsSessionResume: true, + nativeContextManagement: "unknown", + defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY, + }, + pi_local: { + supportsSessionResume: true, + nativeContextManagement: "unknown", + defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY, + }, +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readBoolean(value: unknown): boolean | undefined { + if (typeof value === "boolean") return value; + if (typeof value === "number") { + if (value === 1) return true; + if (value === 0) return false; + return undefined; + } + if (typeof value !== "string") return undefined; + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") { + return true; + } + if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") { + return false; + } + return undefined; +} + +function readNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return Math.max(0, Math.floor(value)); + } + if (typeof value !== "string") return undefined; + const parsed = Number(value.trim()); + return Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : undefined; +} + +export function getAdapterSessionManagement(adapterType: string | null | undefined): AdapterSessionManagement | null { + if (!adapterType) return null; + return ADAPTER_SESSION_MANAGEMENT[adapterType] ?? null; +} + +export function readSessionCompactionOverride(runtimeConfig: unknown): Partial { + const runtime = isRecord(runtimeConfig) ? runtimeConfig : {}; + const heartbeat = isRecord(runtime.heartbeat) ? runtime.heartbeat : {}; + const compaction = isRecord( + heartbeat.sessionCompaction ?? heartbeat.sessionRotation ?? runtime.sessionCompaction, + ) + ? (heartbeat.sessionCompaction ?? heartbeat.sessionRotation ?? runtime.sessionCompaction) as Record + : {}; + + const explicit: Partial = {}; + const enabled = readBoolean(compaction.enabled); + const maxSessionRuns = readNumber(compaction.maxSessionRuns); + const maxRawInputTokens = readNumber(compaction.maxRawInputTokens); + const maxSessionAgeHours = readNumber(compaction.maxSessionAgeHours); + + if (enabled !== undefined) explicit.enabled = enabled; + if (maxSessionRuns !== undefined) explicit.maxSessionRuns = maxSessionRuns; + if (maxRawInputTokens !== undefined) explicit.maxRawInputTokens = maxRawInputTokens; + if (maxSessionAgeHours !== undefined) explicit.maxSessionAgeHours = maxSessionAgeHours; + + return explicit; +} + +export function resolveSessionCompactionPolicy( + adapterType: string | null | undefined, + runtimeConfig: unknown, +): ResolvedSessionCompactionPolicy { + const adapterSessionManagement = getAdapterSessionManagement(adapterType); + const explicitOverride = readSessionCompactionOverride(runtimeConfig); + const hasExplicitOverride = Object.keys(explicitOverride).length > 0; + const fallbackEnabled = Boolean(adapterType && LEGACY_SESSIONED_ADAPTER_TYPES.has(adapterType)); + const basePolicy = adapterSessionManagement?.defaultSessionCompaction ?? { + ...DEFAULT_SESSION_COMPACTION_POLICY, + enabled: fallbackEnabled, + }; + + return { + policy: { + enabled: explicitOverride.enabled ?? basePolicy.enabled, + maxSessionRuns: explicitOverride.maxSessionRuns ?? basePolicy.maxSessionRuns, + maxRawInputTokens: explicitOverride.maxRawInputTokens ?? basePolicy.maxRawInputTokens, + maxSessionAgeHours: explicitOverride.maxSessionAgeHours ?? basePolicy.maxSessionAgeHours, + }, + adapterSessionManagement, + explicitOverride, + source: hasExplicitOverride + ? "agent_override" + : adapterSessionManagement + ? "adapter_default" + : "legacy_fallback", + }; +} + +export function hasSessionCompactionThresholds(policy: Pick< + SessionCompactionPolicy, + "maxSessionRuns" | "maxRawInputTokens" | "maxSessionAgeHours" +>) { + return policy.maxSessionRuns > 0 || policy.maxRawInputTokens > 0 || policy.maxSessionAgeHours > 0; +} diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index ade4648a..f907d4b4 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -216,6 +216,7 @@ export interface ServerAdapterModule { execute(ctx: AdapterExecutionContext): Promise; testEnvironment(ctx: AdapterEnvironmentTestContext): Promise; sessionCodec?: AdapterSessionCodec; + sessionManagement?: import("./session-compaction.js").AdapterSessionManagement; supportsLocalAgentJwt?: boolean; models?: AdapterModel[]; listModels?: () => Promise; diff --git a/scripts/kill-dev.sh b/scripts/kill-dev.sh new file mode 100755 index 00000000..2cb946e2 --- /dev/null +++ b/scripts/kill-dev.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# +# Kill all local Paperclip dev server processes (across all worktrees). +# +# Usage: +# scripts/kill-dev.sh # kill all paperclip dev processes +# scripts/kill-dev.sh --dry # preview what would be killed +# + +set -euo pipefail + +DRY_RUN=false +if [[ "${1:-}" == "--dry" || "${1:-}" == "--dry-run" || "${1:-}" == "-n" ]]; then + DRY_RUN=true +fi + +# Collect PIDs of node processes running from any paperclip directory. +# Matches paths like /Users/*/paperclip/... or /Users/*/paperclip-*/... +# Excludes postgres-related processes. +pids=() +lines=() + +while IFS= read -r line; do + [[ -z "$line" ]] && continue + # skip postgres processes + [[ "$line" == *postgres* ]] && continue + pid=$(echo "$line" | awk '{print $2}') + pids+=("$pid") + lines+=("$line") +done < <(ps aux | grep -E '/paperclip(-[^/]+)?/' | grep node | grep -v grep || true) + +if [[ ${#pids[@]} -eq 0 ]]; then + echo "No Paperclip dev processes found." + exit 0 +fi + +echo "Found ${#pids[@]} Paperclip dev process(es):" +echo "" + +for i in "${!pids[@]}"; do + line="${lines[$i]}" + pid=$(echo "$line" | awk '{print $2}') + start=$(echo "$line" | awk '{print $9}') + cmd=$(echo "$line" | awk '{for(i=11;i<=NF;i++) printf "%s ", $i; print ""}') + # Shorten the command for readability + cmd=$(echo "$cmd" | sed "s|$HOME/||g") + printf " PID %-7s started %-10s %s\n" "$pid" "$start" "$cmd" +done + +echo "" + +if [[ "$DRY_RUN" == true ]]; then + echo "Dry run — re-run without --dry to kill these processes." + exit 0 +fi + +echo "Sending SIGTERM..." +for pid in "${pids[@]}"; do + kill "$pid" 2>/dev/null && echo " killed $pid" || echo " $pid already gone" +done + +# Give processes a moment to exit, then SIGKILL any stragglers +sleep 2 +for pid in "${pids[@]}"; do + if kill -0 "$pid" 2>/dev/null; then + echo " $pid still alive, sending SIGKILL..." + kill -9 "$pid" 2>/dev/null || true + fi +done + +echo "Done." diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index fc21ab9e..1be5cbb7 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; +import type { agents } from "@paperclipai/db"; import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js"; import { prioritizeProjectWorkspaceCandidatesForRun, + parseSessionCompactionPolicy, resolveRuntimeSessionParamsForWorkspace, shouldResetTaskSessionForWake, type ResolvedWorkspaceForRun, @@ -21,6 +23,32 @@ function buildResolvedWorkspace(overrides: Partial = {} }; } +function buildAgent(adapterType: string, runtimeConfig: Record = {}) { + return { + id: "agent-1", + companyId: "company-1", + projectId: null, + goalId: null, + name: "Agent", + role: "engineer", + title: null, + icon: null, + status: "running", + reportsTo: null, + capabilities: null, + adapterType, + adapterConfig: {}, + runtimeConfig, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + permissions: {}, + lastHeartbeatAt: null, + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), + } as unknown as typeof agents.$inferSelect; +} + describe("resolveRuntimeSessionParamsForWorkspace", () => { it("migrates fallback workspace sessions to project workspace when project cwd becomes available", () => { const agentId = "agent-123"; @@ -188,3 +216,55 @@ describe("prioritizeProjectWorkspaceCandidatesForRun", () => { ).toEqual(["workspace-1", "workspace-2"]); }); }); + +describe("parseSessionCompactionPolicy", () => { + it("disables Paperclip-managed rotation by default for codex and claude local", () => { + expect(parseSessionCompactionPolicy(buildAgent("codex_local"))).toEqual({ + enabled: true, + maxSessionRuns: 0, + maxRawInputTokens: 0, + maxSessionAgeHours: 0, + }); + expect(parseSessionCompactionPolicy(buildAgent("claude_local"))).toEqual({ + enabled: true, + maxSessionRuns: 0, + maxRawInputTokens: 0, + maxSessionAgeHours: 0, + }); + }); + + it("keeps conservative defaults for adapters without confirmed native compaction", () => { + expect(parseSessionCompactionPolicy(buildAgent("cursor"))).toEqual({ + enabled: true, + maxSessionRuns: 200, + maxRawInputTokens: 2_000_000, + maxSessionAgeHours: 72, + }); + expect(parseSessionCompactionPolicy(buildAgent("opencode_local"))).toEqual({ + enabled: true, + maxSessionRuns: 200, + maxRawInputTokens: 2_000_000, + maxSessionAgeHours: 72, + }); + }); + + it("lets explicit agent overrides win over adapter defaults", () => { + expect( + parseSessionCompactionPolicy( + buildAgent("codex_local", { + heartbeat: { + sessionCompaction: { + maxSessionRuns: 25, + maxRawInputTokens: 500_000, + }, + }, + }), + ), + ).toEqual({ + enabled: true, + maxSessionRuns: 25, + maxRawInputTokens: 500_000, + maxSessionAgeHours: 0, + }); + }); +}); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index e644900e..dcad7527 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -1,4 +1,5 @@ import type { ServerAdapterModule } from "./types.js"; +import { getAdapterSessionManagement } from "@paperclipai/adapter-utils"; import { execute as claudeExecute, testEnvironment as claudeTestEnvironment, @@ -70,6 +71,7 @@ const claudeLocalAdapter: ServerAdapterModule = { execute: claudeExecute, testEnvironment: claudeTestEnvironment, sessionCodec: claudeSessionCodec, + sessionManagement: getAdapterSessionManagement("claude_local") ?? undefined, models: claudeModels, supportsLocalAgentJwt: true, agentConfigurationDoc: claudeAgentConfigurationDoc, @@ -81,6 +83,7 @@ const codexLocalAdapter: ServerAdapterModule = { execute: codexExecute, testEnvironment: codexTestEnvironment, sessionCodec: codexSessionCodec, + sessionManagement: getAdapterSessionManagement("codex_local") ?? undefined, models: codexModels, listModels: listCodexModels, supportsLocalAgentJwt: true, @@ -93,6 +96,7 @@ const cursorLocalAdapter: ServerAdapterModule = { execute: cursorExecute, testEnvironment: cursorTestEnvironment, sessionCodec: cursorSessionCodec, + sessionManagement: getAdapterSessionManagement("cursor") ?? undefined, models: cursorModels, listModels: listCursorModels, supportsLocalAgentJwt: true, @@ -104,6 +108,7 @@ const geminiLocalAdapter: ServerAdapterModule = { execute: geminiExecute, testEnvironment: geminiTestEnvironment, sessionCodec: geminiSessionCodec, + sessionManagement: getAdapterSessionManagement("gemini_local") ?? undefined, models: geminiModels, supportsLocalAgentJwt: true, agentConfigurationDoc: geminiAgentConfigurationDoc, @@ -123,6 +128,7 @@ const openCodeLocalAdapter: ServerAdapterModule = { execute: openCodeExecute, testEnvironment: openCodeTestEnvironment, sessionCodec: openCodeSessionCodec, + sessionManagement: getAdapterSessionManagement("opencode_local") ?? undefined, models: [], listModels: listOpenCodeModels, supportsLocalAgentJwt: true, @@ -134,6 +140,7 @@ const piLocalAdapter: ServerAdapterModule = { execute: piExecute, testEnvironment: piTestEnvironment, sessionCodec: piSessionCodec, + sessionManagement: getAdapterSessionManagement("pi_local") ?? undefined, models: [], listModels: listPiModels, supportsLocalAgentJwt: true, diff --git a/server/src/adapters/types.ts b/server/src/adapters/types.ts index c5708d8a..88f27218 100644 --- a/server/src/adapters/types.ts +++ b/server/src/adapters/types.ts @@ -3,6 +3,7 @@ // imports (process/, http/, heartbeat.ts) don't need rewriting. export type { AdapterAgent, + AdapterSessionManagement, AdapterRuntime, UsageSummary, AdapterExecutionResult, @@ -15,5 +16,8 @@ export type { AdapterEnvironmentTestContext, AdapterSessionCodec, AdapterModel, + NativeContextManagement, + ResolvedSessionCompactionPolicy, + SessionCompactionPolicy, ServerAdapterModule, } from "@paperclipai/adapter-utils"; diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index ee156091..a966d12b 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -100,6 +100,7 @@ function readSkillMarkdown(skillName: string): string | null { if ( normalized !== "paperclip" && normalized !== "paperclip-create-agent" && + normalized !== "paperclip-create-plugin" && normalized !== "para-memory-files" ) return null; @@ -119,6 +120,90 @@ function readSkillMarkdown(skillName: string): string | null { return null; } +/** Resolve the Paperclip repo skills directory (built-in / managed skills). */ +function resolvePaperclipSkillsDir(): string | null { + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + path.resolve(moduleDir, "../../skills"), // published + path.resolve(process.cwd(), "skills"), // cwd (monorepo root) + path.resolve(moduleDir, "../../../skills"), // dev + ]; + for (const candidate of candidates) { + try { + if (fs.statSync(candidate).isDirectory()) return candidate; + } catch { /* skip */ } + } + return null; +} + +/** Parse YAML frontmatter from a SKILL.md file to extract the description. */ +function parseSkillFrontmatter(markdown: string): { description: string } { + const match = markdown.match(/^---\n([\s\S]*?)\n---/); + if (!match) return { description: "" }; + const yaml = match[1]; + // Extract description — handles both single-line and multi-line YAML values + const descMatch = yaml.match( + /^description:\s*(?:>\s*\n((?:\s{2,}[^\n]*\n?)+)|[|]\s*\n((?:\s{2,}[^\n]*\n?)+)|["']?(.*?)["']?\s*$)/m + ); + if (!descMatch) return { description: "" }; + const raw = descMatch[1] ?? descMatch[2] ?? descMatch[3] ?? ""; + return { + description: raw + .split("\n") + .map((l: string) => l.trim()) + .filter(Boolean) + .join(" ") + .trim(), + }; +} + +interface AvailableSkill { + name: string; + description: string; + isPaperclipManaged: boolean; +} + +/** Discover all available Claude Code skills from ~/.claude/skills/. */ +function listAvailableSkills(): AvailableSkill[] { + const homeDir = process.env.HOME || process.env.USERPROFILE || ""; + const claudeSkillsDir = path.join(homeDir, ".claude", "skills"); + const paperclipSkillsDir = resolvePaperclipSkillsDir(); + + // Build set of Paperclip-managed skill names + const paperclipSkillNames = new Set(); + if (paperclipSkillsDir) { + try { + for (const entry of fs.readdirSync(paperclipSkillsDir, { withFileTypes: true })) { + if (entry.isDirectory()) paperclipSkillNames.add(entry.name); + } + } catch { /* skip */ } + } + + const skills: AvailableSkill[] = []; + + try { + const entries = fs.readdirSync(claudeSkillsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; + if (entry.name.startsWith(".")) continue; + const skillMdPath = path.join(claudeSkillsDir, entry.name, "SKILL.md"); + let description = ""; + try { + const md = fs.readFileSync(skillMdPath, "utf8"); + description = parseSkillFrontmatter(md).description; + } catch { /* no SKILL.md or unreadable */ } + skills.push({ + name: entry.name, + description, + isPaperclipManaged: paperclipSkillNames.has(entry.name), + }); + } + } catch { /* ~/.claude/skills/ doesn't exist */ } + + skills.sort((a, b) => a.name.localeCompare(b.name)); + return skills; +} + function toJoinRequestResponse(row: typeof joinRequests.$inferSelect) { const { claimSecretHash: _claimSecretHash, ...safe } = row; return safe; @@ -1610,6 +1695,10 @@ export function accessRoutes( return { token, created, normalizedAgentMessage }; } + router.get("/skills/available", (_req, res) => { + res.json({ skills: listAvailableSkills() }); + }); + router.get("/skills/index", (_req, res) => { res.json({ skills: [ diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index d9da3094..51555ff5 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -116,7 +116,11 @@ export function projectRoutes(db: Db) { return; } assertCompanyAccess(req, existing.companyId); - const project = await svc.update(id, req.body); + const body = { ...req.body }; + if (typeof body.archivedAt === "string") { + body.archivedAt = new Date(body.archivedAt); + } + const project = await svc.update(id, body); if (!project) { res.status(404).json({ error: "Project not found" }); return; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index d19aaa78..76bac35c 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -48,6 +48,11 @@ import { } from "./execution-workspace-policy.js"; import { instanceSettingsService } from "./instance-settings.js"; import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js"; +import { + hasSessionCompactionThresholds, + resolveSessionCompactionPolicy, + type SessionCompactionPolicy, +} from "@paperclipai/adapter-utils"; const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024; const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1; @@ -203,13 +208,6 @@ type UsageTotals = { outputTokens: number; }; -type SessionCompactionPolicy = { - enabled: boolean; - maxSessionRuns: number; - maxRawInputTokens: number; - maxSessionAgeHours: number; -}; - type SessionCompactionDecision = { rotate: boolean; reason: string | null; @@ -380,23 +378,8 @@ function formatCount(value: number | null | undefined) { return value.toLocaleString("en-US"); } -function parseSessionCompactionPolicy(agent: typeof agents.$inferSelect): SessionCompactionPolicy { - const runtimeConfig = parseObject(agent.runtimeConfig); - const heartbeat = parseObject(runtimeConfig.heartbeat); - const compaction = parseObject( - heartbeat.sessionCompaction ?? heartbeat.sessionRotation ?? runtimeConfig.sessionCompaction, - ); - const supportsSessions = SESSIONED_LOCAL_ADAPTERS.has(agent.adapterType); - const enabled = compaction.enabled === undefined - ? supportsSessions - : asBoolean(compaction.enabled, supportsSessions); - - return { - enabled, - maxSessionRuns: Math.max(0, Math.floor(asNumber(compaction.maxSessionRuns, 200))), - maxRawInputTokens: Math.max(0, Math.floor(asNumber(compaction.maxRawInputTokens, 2_000_000))), - maxSessionAgeHours: Math.max(0, Math.floor(asNumber(compaction.maxSessionAgeHours, 72))), - }; +export function parseSessionCompactionPolicy(agent: typeof agents.$inferSelect): SessionCompactionPolicy { + return resolveSessionCompactionPolicy(agent.adapterType, agent.runtimeConfig).policy; } export function resolveRuntimeSessionParamsForWorkspace(input: { @@ -831,7 +814,7 @@ export function heartbeatService(db: Db) { } const policy = parseSessionCompactionPolicy(agent); - if (!policy.enabled) { + if (!policy.enabled || !hasSessionCompactionThresholds(policy)) { return { rotate: false, reason: null, diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 85486af9..9008fbca 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -144,4 +144,12 @@ export const agentsApi = { ) => api.post(agentPath(id, companyId, "/wakeup"), data), loginWithClaude: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/claude-login"), {}), + availableSkills: () => + api.get<{ skills: AvailableSkill[] }>("/skills/available"), }; + +export interface AvailableSkill { + name: string; + description: string; + isPaperclipManaged: boolean; +} diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index abfc04fb..c6e48cd0 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1,6 +1,11 @@ import { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared"; +import { + hasSessionCompactionThresholds, + resolveSessionCompactionPolicy, + type ResolvedSessionCompactionPolicy, +} from "@paperclipai/adapter-utils"; import type { Agent, AdapterEnvironmentTestResult, @@ -383,6 +388,31 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const codexSearchEnabled = adapterType === "codex_local" ? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search))) : false; + const effectiveRuntimeConfig = useMemo(() => { + if (isCreate) { + return { + heartbeat: { + enabled: val!.heartbeatEnabled, + intervalSec: val!.intervalSec, + }, + }; + } + const mergedHeartbeat = { + ...(runtimeConfig.heartbeat && typeof runtimeConfig.heartbeat === "object" + ? runtimeConfig.heartbeat as Record + : {}), + ...overlay.heartbeat, + }; + return { + ...runtimeConfig, + heartbeat: mergedHeartbeat, + }; + }, [isCreate, overlay.heartbeat, runtimeConfig, val]); + const sessionCompaction = useMemo( + () => resolveSessionCompactionPolicy(adapterType, effectiveRuntimeConfig), + [adapterType, effectiveRuntimeConfig], + ); + const showSessionCompactionCard = Boolean(sessionCompaction.adapterSessionManagement); return (
@@ -813,6 +843,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) { numberHint={help.intervalSec} showNumber={val!.heartbeatEnabled} /> + {showSessionCompactionCard && ( + + )}
) : ( @@ -835,6 +871,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) { numberHint={help.intervalSec} showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)} /> + {showSessionCompactionCard && ( + + )} +
+
Session compaction
+ + {sourceLabel} + +
+

+ {nativeSummary} +

+

+ {rotationDisabled + ? "No Paperclip-managed fresh-session thresholds are active for this adapter." + : "Paperclip will start a fresh session when one of these thresholds is reached."} +

+
+
+
Runs
+
{formatSessionThreshold(policy.maxSessionRuns, "runs")}
+
+
+
Raw input
+
{formatSessionThreshold(policy.maxRawInputTokens, "tokens")}
+
+
+
Age
+
{formatSessionThreshold(policy.maxSessionAgeHours, "hours")}
+
+
+

+ A large cumulative raw token total does not mean the full session is resent on every heartbeat. + {source === "agent_override" && " This agent has an explicit runtimeConfig session compaction override."} +

+ + ); +} + /* ---- Internal sub-components ---- */ const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor"]); diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 372b8a4d..29a57a3a 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -61,6 +61,10 @@ export interface MarkdownEditorRef { focus: () => void; } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + /* ---- Mention detection helpers ---- */ interface MentionState { @@ -251,6 +255,24 @@ export const MarkdownEditor = forwardRef try { const src = await handler(file); setUploadError(null); + // After MDXEditor inserts the image, ensure two newlines follow it + // so the cursor isn't stuck right next to the image. + setTimeout(() => { + const current = latestValueRef.current; + const escapedSrc = escapeRegExp(src); + const updated = current.replace( + new RegExp(`(!\\[[^\\]]*\\]\\(${escapedSrc}\\))(?!\\n\\n)`, "g"), + "$1\n\n", + ); + if (updated !== current) { + latestValueRef.current = updated; + ref.current?.setMarkdown(updated); + onChange(updated); + requestAnimationFrame(() => { + ref.current?.focus(undefined, { defaultSelection: "rootEnd" }); + }); + } + }, 100); return src; } catch (err) { const message = err instanceof Error ? err.message : "Image upload failed"; diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx index edff1523..06645118 100644 --- a/ui/src/components/ProjectProperties.tsx +++ b/ui/src/components/ProjectProperties.tsx @@ -150,6 +150,71 @@ function ProjectStatusPicker({ status, onChange }: { status: string; onChange: ( ); } +function ArchiveDangerZone({ + project, + onArchive, + archivePending, +}: { + project: Project; + onArchive: (archived: boolean) => void; + archivePending?: boolean; +}) { + const [confirming, setConfirming] = useState(false); + const isArchive = !project.archivedAt; + const action = isArchive ? "Archive" : "Unarchive"; + + return ( +
+

+ {isArchive + ? "Archive this project to hide it from the sidebar and project selectors." + : "Unarchive this project to restore it in the sidebar and project selectors."} +

+ {archivePending ? ( + + ) : confirming ? ( +
+ + {action} “{project.name}”? + + + +
+ ) : ( + + )} +
+ ); +} + export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSaveState, onArchive, archivePending }: ProjectPropertiesProps) { const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); @@ -1046,34 +1111,11 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
Danger Zone
-
-

- {project.archivedAt - ? "Unarchive this project to restore it in the sidebar and project selectors." - : "Archive this project to hide it from the sidebar and project selectors."} -

- -
+ )} diff --git a/ui/src/index.css b/ui/src/index.css index c9ee652f..6a821296 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -377,21 +377,21 @@ } .paperclip-mdxeditor-content h1 { - margin: 0 0 0.9em; + margin: 1.4em 0 0.9em; font-size: 1.75em; font-weight: 700; line-height: 1.2; } .paperclip-mdxeditor-content h2 { - margin: 0 0 0.85em; + margin: 1.3em 0 0.85em; font-size: 1.35em; font-weight: 700; line-height: 1.3; } .paperclip-mdxeditor-content h3 { - margin: 0 0 0.8em; + margin: 1.2em 0 0.8em; font-size: 1.15em; font-weight: 600; line-height: 1.35; @@ -585,8 +585,11 @@ color: var(--muted-foreground); } -.paperclip-markdown :where(h1, h2, h3, h4) { - margin-top: 1.15rem; +.paperclip-markdown h1, +.paperclip-markdown h2, +.paperclip-markdown h3, +.paperclip-markdown h4 { + margin-top: 1.75rem; margin-bottom: 0.45rem; color: var(--foreground); font-weight: 600; diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 7016e5e3..bf06185c 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -104,6 +104,9 @@ export const queryKeys = { liveRuns: (companyId: string) => ["live-runs", companyId] as const, runIssues: (runId: string) => ["run-issues", runId] as const, org: (companyId: string) => ["org", companyId] as const, + skills: { + available: ["skills", "available"] as const, + }, plugins: { all: ["plugins"] as const, examples: ["plugins", "examples"] as const, diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index b82defd2..e9c33364 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { useParams, useNavigate, Link, Navigate, useBeforeUnload } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents"; +import { agentsApi, type AgentKey, type ClaudeLoginResult, type AvailableSkill } from "../api/agents"; import { budgetsApi } from "../api/budgets"; import { heartbeatsApi } from "../api/heartbeats"; import { ApiError } from "../api/client"; @@ -30,6 +30,7 @@ import { ScrollToBottom } from "../components/ScrollToBottom"; import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils"; import { cn } from "../lib/utils"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { Tabs } from "@/components/ui/tabs"; import { Popover, @@ -186,11 +187,12 @@ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBeh container.scrollTo({ top: container.scrollHeight, behavior }); } -type AgentDetailView = "dashboard" | "configuration" | "runs" | "budget"; +type AgentDetailView = "dashboard" | "configuration" | "skills" | "runs" | "budget"; function parseAgentDetailView(value: string | null): AgentDetailView { if (value === "configure" || value === "configuration") return "configuration"; - if (value === "budget") return "budget"; + if (value === "skills") return value; + if (value === "budget") return value; if (value === "runs") return value; return "dashboard"; } @@ -578,10 +580,12 @@ export function AgentDetail() { const canonicalTab = activeView === "configuration" ? "configuration" - : activeView === "runs" - ? "runs" - : activeView === "budget" - ? "budget" + : activeView === "skills" + ? "skills" + : activeView === "runs" + ? "runs" + : activeView === "budget" + ? "budget" : "dashboard"; if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) { navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true }); @@ -697,6 +701,8 @@ export function AgentDetail() { crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` }); } else if (activeView === "configuration") { crumbs.push({ label: "Configuration" }); + } else if (activeView === "skills") { + crumbs.push({ label: "Skills" }); } else if (activeView === "runs") { crumbs.push({ label: "Runs" }); } else if (activeView === "budget") { @@ -856,6 +862,7 @@ export function AgentDetail() { items={[ { value: "dashboard", label: "Dashboard" }, { value: "configuration", label: "Configuration" }, + { value: "skills", label: "Skills" }, { value: "runs", label: "Runs" }, { value: "budget", label: "Budget" }, ]} @@ -873,14 +880,9 @@ export function AgentDetail() { )} {/* Floating Save/Cancel (desktop) */} - {!isMobile && ( + {!isMobile && showConfigActionBar && (