Merge public-gh/master into paperclip-subissues
This commit is contained in:
@@ -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<ResolvedWorkspaceForRun> = {}
|
||||
};
|
||||
}
|
||||
|
||||
function buildAgent(adapterType: string, runtimeConfig: Record<string, unknown> = {}) {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string>();
|
||||
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: [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user