mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 11:40:39 +09:00
Merge public-gh/master into paperclip-company-import-export
This commit is contained in:
commit
2a7c44d314
33 changed files with 987 additions and 81 deletions
|
|
@ -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,
|
||||
|
|
|
|||
175
packages/adapter-utils/src/session-compaction.ts
Normal file
175
packages/adapter-utils/src/session-compaction.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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[]>;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue