Merge public-gh/master into paperclip-company-import-export

This commit is contained in:
Dotta 2026-03-17 10:19:31 -05:00
commit 2a7c44d314
33 changed files with 987 additions and 81 deletions

View file

@ -29,6 +29,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,

View file

@ -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<SessionCompactionPolicy>;
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<string, AdapterSessionManagement> = {
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<string, unknown> {
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<SessionCompactionPolicy> {
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<string, unknown>
: {};
const explicit: Partial<SessionCompactionPolicy> = {};
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;
}

View file

@ -257,6 +257,7 @@ export interface ServerAdapterModule {
listSkills?: (ctx: AdapterSkillContext) => Promise<AdapterSkillSnapshot>;
syncSkills?: (ctx: AdapterSkillContext, desiredSkills: string[]) => Promise<AdapterSkillSnapshot>;
sessionCodec?: AdapterSessionCodec;
sessionManagement?: import("./session-compaction.js").AdapterSessionManagement;
supportsLocalAgentJwt?: boolean;
models?: AdapterModel[];
listModels?: () => Promise<AdapterModel[]>;

View file

@ -165,6 +165,14 @@ export interface HostServices {
createComment(params: WorkerToHostMethods["issues.createComment"][0]): Promise<WorkerToHostMethods["issues.createComment"][1]>;
};
/** Provides `issues.documents.list`, `issues.documents.get`, `issues.documents.upsert`, `issues.documents.delete`. */
issueDocuments: {
list(params: WorkerToHostMethods["issues.documents.list"][0]): Promise<WorkerToHostMethods["issues.documents.list"][1]>;
get(params: WorkerToHostMethods["issues.documents.get"][0]): Promise<WorkerToHostMethods["issues.documents.get"][1]>;
upsert(params: WorkerToHostMethods["issues.documents.upsert"][0]): Promise<WorkerToHostMethods["issues.documents.upsert"][1]>;
delete(params: WorkerToHostMethods["issues.documents.delete"][0]): Promise<WorkerToHostMethods["issues.documents.delete"][1]>;
};
/** Provides `agents.list`, `agents.get`, `agents.pause`, `agents.resume`, `agents.invoke`. */
agents: {
list(params: WorkerToHostMethods["agents.list"][0]): Promise<WorkerToHostMethods["agents.list"][1]>;
@ -298,6 +306,12 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
"issues.listComments": "issue.comments.read",
"issues.createComment": "issue.comments.create",
// Issue Documents
"issues.documents.list": "issue.documents.read",
"issues.documents.get": "issue.documents.read",
"issues.documents.upsert": "issue.documents.write",
"issues.documents.delete": "issue.documents.write",
// Agents
"agents.list": "agents.read",
"agents.get": "agents.read",
@ -483,6 +497,20 @@ export function createHostClientHandlers(
return services.issues.createComment(params);
}),
// Issue Documents
"issues.documents.list": gated("issues.documents.list", async (params) => {
return services.issueDocuments.list(params);
}),
"issues.documents.get": gated("issues.documents.get", async (params) => {
return services.issueDocuments.get(params);
}),
"issues.documents.upsert": gated("issues.documents.upsert", async (params) => {
return services.issueDocuments.upsert(params);
}),
"issues.documents.delete": gated("issues.documents.delete", async (params) => {
return services.issueDocuments.delete(params);
}),
// Agents
"agents.list": gated("agents.list", async (params) => {
return services.agents.list(params);

View file

@ -25,6 +25,8 @@ import type {
Project,
Issue,
IssueComment,
IssueDocument,
IssueDocumentSummary,
Agent,
Goal,
} from "@paperclipai/shared";
@ -601,6 +603,32 @@ export interface WorkerToHostMethods {
result: IssueComment,
];
// Issue Documents
"issues.documents.list": [
params: { issueId: string; companyId: string },
result: IssueDocumentSummary[],
];
"issues.documents.get": [
params: { issueId: string; key: string; companyId: string },
result: IssueDocument | null,
];
"issues.documents.upsert": [
params: {
issueId: string;
key: string;
body: string;
companyId: string;
title?: string;
format?: string;
changeSummary?: string;
},
result: IssueDocument,
];
"issues.documents.delete": [
params: { issueId: string; key: string; companyId: string },
result: void,
];
// Agents (read)
"agents.list": [
params: { companyId: string; status?: string; limit?: number; offset?: number },

View file

@ -422,6 +422,33 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
issueComments.set(issueId, current);
return comment;
},
documents: {
async list(issueId, companyId) {
requireCapability(manifest, capabilitySet, "issue.documents.read");
if (!isInCompany(issues.get(issueId), companyId)) return [];
return [];
},
async get(issueId, _key, companyId) {
requireCapability(manifest, capabilitySet, "issue.documents.read");
if (!isInCompany(issues.get(issueId), companyId)) return null;
return null;
},
async upsert(input) {
requireCapability(manifest, capabilitySet, "issue.documents.write");
const parentIssue = issues.get(input.issueId);
if (!isInCompany(parentIssue, input.companyId)) {
throw new Error(`Issue not found: ${input.issueId}`);
}
throw new Error("documents.upsert is not implemented in test context");
},
async delete(issueId, _key, companyId) {
requireCapability(manifest, capabilitySet, "issue.documents.write");
const parentIssue = issues.get(issueId);
if (!isInCompany(parentIssue, companyId)) {
throw new Error(`Issue not found: ${issueId}`);
}
},
},
},
agents: {
async list(input) {

View file

@ -19,6 +19,8 @@ import type {
Project,
Issue,
IssueComment,
IssueDocument,
IssueDocumentSummary,
Agent,
Goal,
} from "@paperclipai/shared";
@ -61,6 +63,8 @@ export type {
Project,
Issue,
IssueComment,
IssueDocument,
IssueDocumentSummary,
Agent,
Goal,
} from "@paperclipai/shared";
@ -774,6 +778,73 @@ export interface PluginCompaniesClient {
get(companyId: string): Promise<Company | null>;
}
/**
* `ctx.issues.documents` read and write issue documents.
*
* Requires:
* - `issue.documents.read` for `list` and `get`
* - `issue.documents.write` for `upsert` and `delete`
*
* @see PLUGIN_SPEC.md §14 SDK Surface
*/
export interface PluginIssueDocumentsClient {
/**
* List all documents attached to an issue.
*
* Returns summary metadata (id, key, title, format, timestamps) without
* the full document body. Use `get()` to fetch a specific document's body.
*
* Requires the `issue.documents.read` capability.
*/
list(issueId: string, companyId: string): Promise<IssueDocumentSummary[]>;
/**
* Get a single document by key, including its full body content.
*
* Returns `null` if no document exists with the given key.
*
* Requires the `issue.documents.read` capability.
*
* @param issueId - UUID of the issue
* @param key - Document key (e.g. `"plan"`, `"design-spec"`)
* @param companyId - UUID of the company
*/
get(issueId: string, key: string, companyId: string): Promise<IssueDocument | null>;
/**
* Create or update a document on an issue.
*
* If a document with the given key already exists, it is updated and a new
* revision is created. If it does not exist, it is created.
*
* Requires the `issue.documents.write` capability.
*
* @param input - Document data including issueId, key, body, and optional title/format/changeSummary
*/
upsert(input: {
issueId: string;
key: string;
body: string;
companyId: string;
title?: string;
format?: string;
changeSummary?: string;
}): Promise<IssueDocument>;
/**
* Delete a document and all its revisions.
*
* No-ops silently if the document does not exist (idempotent).
*
* Requires the `issue.documents.write` capability.
*
* @param issueId - UUID of the issue
* @param key - Document key to delete
* @param companyId - UUID of the company
*/
delete(issueId: string, key: string, companyId: string): Promise<void>;
}
/**
* `ctx.issues` read and mutate issues plus comments.
*
@ -783,6 +854,8 @@ export interface PluginCompaniesClient {
* - `issues.update` for update
* - `issue.comments.read` for `listComments`
* - `issue.comments.create` for `createComment`
* - `issue.documents.read` for `documents.list` and `documents.get`
* - `issue.documents.write` for `documents.upsert` and `documents.delete`
*/
export interface PluginIssuesClient {
list(input: {
@ -814,6 +887,8 @@ export interface PluginIssuesClient {
): Promise<Issue>;
listComments(issueId: string, companyId: string): Promise<IssueComment[]>;
createComment(issueId: string, body: string, companyId: string): Promise<IssueComment>;
/** Read and write issue documents. Requires `issue.documents.read` / `issue.documents.write`. */
documents: PluginIssueDocumentsClient;
}
/**
@ -1056,7 +1131,7 @@ export interface PluginContext {
/** Read company metadata. Requires `companies.read`. */
companies: PluginCompaniesClient;
/** Read and write issues/comments. Requires issue capabilities. */
/** Read and write issues, comments, and documents. Requires issue capabilities. */
issues: PluginIssuesClient;
/** Read and manage agents. Requires `agents.read` for reads; `agents.pause` / `agents.resume` / `agents.invoke` for write ops. */

View file

@ -612,6 +612,32 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
async createComment(issueId: string, body: string, companyId: string) {
return callHost("issues.createComment", { issueId, body, companyId });
},
documents: {
async list(issueId: string, companyId: string) {
return callHost("issues.documents.list", { issueId, companyId });
},
async get(issueId: string, key: string, companyId: string) {
return callHost("issues.documents.get", { issueId, key, companyId });
},
async upsert(input) {
return callHost("issues.documents.upsert", {
issueId: input.issueId,
key: input.key,
body: input.body,
companyId: input.companyId,
title: input.title,
format: input.format,
changeSummary: input.changeSummary,
});
},
async delete(issueId: string, key: string, companyId: string) {
return callHost("issues.documents.delete", { issueId, key, companyId });
},
},
},
agents: {

View file

@ -385,6 +385,7 @@ export const PLUGIN_CAPABILITIES = [
"project.workspaces.read",
"issues.read",
"issue.comments.read",
"issue.documents.read",
"agents.read",
"goals.read",
"goals.create",
@ -395,6 +396,7 @@ export const PLUGIN_CAPABILITIES = [
"issues.create",
"issues.update",
"issue.comments.create",
"issue.documents.write",
"agents.pause",
"agents.resume",
"agents.invoke",