Compare commits

..

12 Commits

Author SHA1 Message Date
Dotta
24d6e3a543 fix: align embedded postgres ctor types with initdbFlags usage 2026-03-13 09:25:04 -05:00
Dotta
0b8223b8b9 Merge pull request #796 from paperclipai/docs/organize-and-date-plans
docs: consolidate dated plan docs and naming guidance
2026-03-13 09:20:25 -05:00
Dotta
e2f0241533 docs: add dated plan naming rule and align workspace plan 2026-03-13 09:16:28 -05:00
Dotta
89e247b410 Expand workspace plan for migration and cloud execution 2026-03-13 09:15:36 -05:00
Dotta
216cb3fb28 Add workspace product model plan 2026-03-13 09:15:36 -05:00
Dotta
84fc6d4a87 docs: add token optimization plan
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 09:15:36 -05:00
Dotta
9c7d9ded1e docs: organize plans into doc/plans with date prefixes
Move plans from doc/plan/ into doc/plans/ and add YYYY-MM-DD date
prefixes to all undated plan files based on document headers or
earliest git commit dates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 09:11:56 -05:00
Dotta
dfe40ffcca Merge pull request #793 from paperclipai/split/docs-sidebar-link
ui: point Documentation sidebar link to docs.paperclip.ing
2026-03-13 09:09:30 -05:00
Dotta
f477f23738 Merge pull request #794 from paperclipai/split/agent-skill-resolution
fix(adapters): resolve local agent skill lookup after .agents migration
2026-03-13 09:09:22 -05:00
Dotta
29a743cb9e fix: keep runtime skills scoped to ./skills
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 08:49:25 -05:00
Dotta
4e759da070 fix: prefer .agents skills and repair codex symlink targets\n\nCo-Authored-By: Paperclip <noreply@paperclip.ing> 2026-03-13 08:49:25 -05:00
Paperclip
5c7d2116e9 Fix local-cli skill install for moved .agents skills
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-13 08:49:24 -05:00
26 changed files with 1868 additions and 122 deletions

View File

@@ -78,6 +78,9 @@ If you change schema/API behavior, update all impacted layers:
4. Do not replace strategic docs wholesale unless asked.
Prefer additive updates. Keep `doc/SPEC.md` and `doc/SPEC-implementation.md` aligned.
5. Keep plan docs dated and centralized.
New plan documents belong in `doc/plans/` and should use `YYYY-MM-DD-slug.md` filenames.
## 6. Database Change Workflow
When changing data model:

View File

@@ -1,5 +1,9 @@
import { Command } from "commander";
import type { Agent } from "@paperclipai/shared";
import {
removeMaintainerOnlySkillSymlinks,
resolvePaperclipSkillsDir,
} from "@paperclipai/adapter-utils/server-utils";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -34,15 +38,12 @@ interface SkillsInstallSummary {
tool: "codex" | "claude";
target: string;
linked: string[];
removed: string[];
skipped: string[];
failed: Array<{ name: string; error: string }>;
}
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const PAPERCLIP_SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../../../../skills"), // dev: cli/src/commands/client -> repo root/skills
path.resolve(process.cwd(), "skills"),
];
function codexSkillsHome(): string {
const fromEnv = process.env.CODEX_HOME?.trim();
@@ -56,14 +57,6 @@ function claudeSkillsHome(): string {
return path.join(base, "skills");
}
async function resolvePaperclipSkillsDir(): Promise<string | null> {
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
}
return null;
}
async function installSkillsForTarget(
sourceSkillsDir: string,
targetSkillsDir: string,
@@ -73,20 +66,65 @@ async function installSkillsForTarget(
tool,
target: targetSkillsDir,
linked: [],
removed: [],
skipped: [],
failed: [],
};
await fs.mkdir(targetSkillsDir, { recursive: true });
const entries = await fs.readdir(sourceSkillsDir, { withFileTypes: true });
summary.removed = await removeMaintainerOnlySkillSymlinks(
targetSkillsDir,
entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name),
);
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const source = path.join(sourceSkillsDir, entry.name);
const target = path.join(targetSkillsDir, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (existing) {
summary.skipped.push(entry.name);
continue;
if (existing.isSymbolicLink()) {
let linkedPath: string | null = null;
try {
linkedPath = await fs.readlink(target);
} catch (err) {
await fs.unlink(target);
try {
await fs.symlink(source, target);
summary.linked.push(entry.name);
continue;
} catch (linkErr) {
summary.failed.push({
name: entry.name,
error:
err instanceof Error && linkErr instanceof Error
? `${err.message}; then ${linkErr.message}`
: err instanceof Error
? err.message
: `Failed to recover broken symlink: ${String(err)}`,
});
continue;
}
}
const resolvedLinkedPath = path.isAbsolute(linkedPath)
? linkedPath
: path.resolve(path.dirname(target), linkedPath);
const linkedTargetExists = await fs
.stat(resolvedLinkedPath)
.then(() => true)
.catch(() => false);
if (!linkedTargetExists) {
await fs.unlink(target);
} else {
summary.skipped.push(entry.name);
continue;
}
} else {
summary.skipped.push(entry.name);
continue;
}
}
try {
@@ -210,7 +248,7 @@ export function registerAgentCommands(program: Command): void {
const installSummaries: SkillsInstallSummary[] = [];
if (opts.installSkills !== false) {
const skillsDir = await resolvePaperclipSkillsDir();
const skillsDir = await resolvePaperclipSkillsDir(__moduleDir, [path.resolve(process.cwd(), "skills")]);
if (!skillsDir) {
throw new Error(
"Could not locate local Paperclip skills directory. Expected ./skills in the repo checkout.",
@@ -258,7 +296,7 @@ export function registerAgentCommands(program: Command): void {
if (installSummaries.length > 0) {
for (const summary of installSummaries) {
console.log(
`${summary.tool}: linked=${summary.linked.length} skipped=${summary.skipped.length} failed=${summary.failed.length} target=${summary.target}`,
`${summary.tool}: linked=${summary.linked.length} removed=${summary.removed.length} skipped=${summary.skipped.length} failed=${summary.failed.length} target=${summary.target}`,
);
for (const failed of summary.failed) {
console.log(` failed ${failed.name}: ${failed.error}`);

View File

@@ -83,6 +83,7 @@ type EmbeddedPostgresCtor = new (opts: {
password: string;
port: number;
persistent: boolean;
initdbFlags?: string[];
onLog?: (message: unknown) => void;
onError?: (message: unknown) => void;
}) => EmbeddedPostgresInstance;

View File

@@ -0,0 +1,383 @@
# Token Optimization Plan
Date: 2026-03-13
Related discussion: https://github.com/paperclipai/paperclip/discussions/449
## Goal
Reduce token consumption materially without reducing agent capability, control-plane visibility, or task completion quality.
This plan is based on:
- the current V1 control-plane design
- the current adapter and heartbeat implementation
- the linked user discussion
- local runtime data from the default Paperclip instance on 2026-03-13
## Executive Summary
The discussion is directionally right about two things:
1. We should preserve session and prompt-cache locality more aggressively.
2. We should separate stable startup instructions from per-heartbeat dynamic context.
But that is not enough on its own.
After reviewing the code and local run data, the token problem appears to have four distinct causes:
1. **Measurement inflation on sessioned adapters.** Some token counters, especially for `codex_local`, appear to be recorded as cumulative session totals instead of per-heartbeat deltas.
2. **Avoidable session resets.** Task sessions are intentionally reset on timer wakes and manual wakes, which destroys cache locality for common heartbeat paths.
3. **Repeated context reacquisition.** The `paperclip` skill tells agents to re-fetch assignments, issue details, ancestors, and full comment threads on every heartbeat. The API does not currently offer efficient delta-oriented alternatives.
4. **Large static instruction surfaces.** Agent instruction files and globally injected skills are reintroduced at startup even when most of that content is unchanged and not needed for the current task.
The correct approach is:
1. fix telemetry so we can trust the numbers
2. preserve reuse where it is safe
3. make context retrieval incremental
4. add session compaction/rotation so long-lived sessions do not become progressively more expensive
## Validated Findings
### 1. Token telemetry is at least partly overstated today
Observed from the local default instance:
- `heartbeat_runs`: 11,360 runs between 2026-02-18 and 2026-03-13
- summed `usage_json.inputTokens`: `2,272,142,368,952`
- summed `usage_json.cachedInputTokens`: `2,217,501,559,420`
Those totals are not credible as true per-heartbeat usage for the observed prompt sizes.
Supporting evidence:
- `adapter.invoke.payload.prompt` averages were small:
- `codex_local`: ~193 chars average, 6,067 chars max
- `claude_local`: ~160 chars average, 1,160 chars max
- despite that, many `codex_local` runs report millions of input tokens
- one reused Codex session in local data spans 3,607 runs and recorded `inputTokens` growing up to `1,155,283,166`
Interpretation:
- for sessioned adapters, especially Codex, we are likely storing usage reported by the runtime as a **session total**, not a **per-run delta**
- this makes trend reporting, optimization work, and customer trust worse
This does **not** mean there is no real token problem. It means we need a trustworthy baseline before we can judge optimization impact.
### 2. Timer wakes currently throw away reusable task sessions
In `server/src/services/heartbeat.ts`, `shouldResetTaskSessionForWake(...)` returns `true` for:
- `wakeReason === "issue_assigned"`
- `wakeSource === "timer"`
- manual on-demand wakes
That means many normal heartbeats skip saved task-session resume even when the workspace is stable.
Local data supports the impact:
- `timer/system` runs: 6,587 total
- only 976 had a previous session
- only 963 ended with the same session
So timer wakes are the largest heartbeat path and are mostly not resuming prior task state.
### 3. We repeatedly ask agents to reload the same task context
The `paperclip` skill currently tells agents to do this on essentially every heartbeat:
- fetch assignments
- fetch issue details
- fetch ancestor chain
- fetch full issue comments
Current API shape reinforces that pattern:
- `GET /api/issues/:id/comments` returns the full thread
- there is no `since`, cursor, digest, or summary endpoint for heartbeat consumption
- `GET /api/issues/:id` returns full enriched issue context, not a minimal delta payload
This is safe but expensive. It forces the model to repeatedly consume unchanged information.
### 4. Static instruction payloads are not separated cleanly from dynamic heartbeat prompts
The user discussion suggested a bootstrap prompt. That is the right direction.
Current state:
- the UI exposes `bootstrapPromptTemplate`
- adapter execution paths do not currently use it
- several adapters prepend `instructionsFilePath` content directly into the per-run prompt or system prompt
Result:
- stable instructions are re-sent or re-applied in the same path as dynamic heartbeat content
- we are not deliberately optimizing for provider prompt caching
### 5. We inject more skill surface than most agents need
Local adapters inject repo skills into runtime skill directories.
Current repo skill sizes:
- `skills/paperclip/SKILL.md`: 17,441 bytes
- `skills/create-agent-adapter/SKILL.md`: 31,832 bytes
- `skills/paperclip-create-agent/SKILL.md`: 4,718 bytes
- `skills/para-memory-files/SKILL.md`: 3,978 bytes
That is nearly 58 KB of skill markdown before any company-specific instructions.
Not all of that is necessarily loaded into model context every run, but it increases startup surface area and should be treated as a token budget concern.
## Principles
We should optimize tokens under these rules:
1. **Do not lose functionality.** Agents must still be able to resume work safely, understand why tasks exist, and act within governance rules.
2. **Prefer stable context over repeated context.** Unchanged instructions should not be resent through the most expensive path.
3. **Prefer deltas over full reloads.** Heartbeats should consume only what changed since the last useful run.
4. **Measure normalized deltas, not raw adapter claims.** Especially for sessioned CLIs.
5. **Keep escape hatches.** Board/manual runs may still want a forced fresh session.
## Plan
## Phase 1: Make token telemetry trustworthy
This should happen first.
### Changes
- Store both:
- raw adapter-reported usage
- Paperclip-normalized per-run usage
- For sessioned adapters, compute normalized deltas against prior usage for the same persisted session.
- Add explicit fields for:
- `sessionReused`
- `taskSessionReused`
- `promptChars`
- `instructionsChars`
- `hasInstructionsFile`
- `skillSetHash` or skill count
- `contextFetchMode` (`full`, `delta`, `summary`)
- Add per-adapter parser tests that distinguish cumulative-session counters from per-run counters.
### Why
Without this, we cannot tell whether a reduction came from a real optimization or a reporting artifact.
### Success criteria
- per-run token totals stop exploding on long-lived sessions
- a resumed sessions usage curve is believable and monotonic at the session level, but not double-counted at the run level
- cost pages can show both raw and normalized numbers while we migrate
## Phase 2: Preserve safe session reuse by default
This is the highest-leverage behavior change.
### Changes
- Stop resetting task sessions on ordinary timer wakes.
- Keep resetting on:
- explicit manual “fresh run” invocations
- assignment changes
- workspace mismatch
- model mismatch / invalid resume errors
- Add an explicit wake flag like `forceFreshSession: true` when the board wants a reset.
- Record why a session was reused or reset in run metadata.
### Why
Timer wakes are the dominant heartbeat path. Resetting them destroys both session continuity and prompt cache reuse.
### Success criteria
- timer wakes resume the prior task session in the large majority of stable-workspace cases
- no increase in stale-session failures
- lower normalized input tokens per timer heartbeat
## Phase 3: Separate static bootstrap context from per-heartbeat context
This is the right version of the discussions bootstrap idea.
### Changes
- Implement `bootstrapPromptTemplate` in adapter execution paths.
- Use it only when starting a fresh session, not on resumed sessions.
- Keep `promptTemplate` intentionally small and stable:
- who I am
- what triggered this wake
- which task/comment/approval to prioritize
- Move long-lived setup text out of recurring per-run prompts where possible.
- Add UI guidance and warnings when `promptTemplate` contains high-churn or large inline content.
### Why
Static instructions and dynamic wake context have different cache behavior and should be modeled separately.
### Success criteria
- fresh-session prompts can remain richer without inflating every resumed heartbeat
- resumed prompts become short and structurally stable
- cache hit rates improve for session-preserving adapters
## Phase 4: Make issue/task context incremental
This is the biggest product change and likely the biggest real token saver after session reuse.
### Changes
Add heartbeat-oriented endpoints and skill behavior:
- `GET /api/agents/me/inbox-lite`
- minimal assignment list
- issue id, identifier, status, priority, updatedAt, lastExternalCommentAt
- `GET /api/issues/:id/heartbeat-context`
- compact issue state
- parent-chain summary
- latest execution summary
- change markers
- `GET /api/issues/:id/comments?after=<cursor>` or `?since=<timestamp>`
- return only new comments
- optional `GET /api/issues/:id/context-digest`
- server-generated compact summary for heartbeat use
Update the `paperclip` skill so the default pattern becomes:
1. fetch compact inbox
2. fetch compact task context
3. fetch only new comments unless this is the first read, a mention-triggered wake, or a cache miss
4. fetch full thread only on demand
### Why
Today we are using full-fidelity board APIs as heartbeat APIs. That is convenient but token-inefficient.
### Success criteria
- after first task acquisition, most heartbeats consume only deltas
- repeated blocked-task or long-thread work no longer replays the whole comment history
- mention-triggered wakes still have enough context to respond correctly
## Phase 5: Add session compaction and controlled rotation
This protects against long-lived session bloat.
### Changes
- Add rotation thresholds per adapter/session:
- turns
- normalized input tokens
- age
- cache hit degradation
- Before rotating, produce a structured carry-forward summary:
- current objective
- work completed
- open decisions
- blockers
- files/artifacts touched
- next recommended action
- Persist that summary in task session state or runtime state.
- Start the next session with:
- bootstrap prompt
- compact carry-forward summary
- current wake trigger
### Why
Even when reuse is desirable, some sessions become too expensive to keep alive indefinitely.
### Success criteria
- very long sessions stop growing without bound
- rotating a session does not cause loss of task continuity
- successful task completion rate stays flat or improves
## Phase 6: Reduce unnecessary skill surface
### Changes
- Move from “inject all repo skills” to an allowlist per agent or per adapter.
- Default local runtime skill set should likely be:
- `paperclip`
- Add opt-in skills for specialized agents:
- `paperclip-create-agent`
- `para-memory-files`
- `create-agent-adapter`
- Expose active skill set in agent config and run metadata.
### Why
Most agents do not need adapter-authoring or memory-system skills on every run.
### Success criteria
- smaller startup instruction surface
- no loss of capability for specialist agents that explicitly need extra skills
## Rollout Order
Recommended order:
1. telemetry normalization
2. timer-wake session reuse
3. bootstrap prompt implementation
4. heartbeat delta APIs + `paperclip` skill rewrite
5. session compaction/rotation
6. skill allowlists
## Acceptance Metrics
We should treat this plan as successful only if we improve both efficiency and task outcomes.
Primary metrics:
- normalized input tokens per successful heartbeat
- normalized input tokens per completed issue
- cache-hit ratio for sessioned adapters
- session reuse rate by invocation source
- fraction of heartbeats that fetch full comment threads
Guardrail metrics:
- task completion rate
- blocked-task rate
- stale-session failure rate
- manual intervention rate
- issue reopen rate after agent completion
Initial targets:
- 30% to 50% reduction in normalized input tokens per successful resumed heartbeat
- 80%+ session reuse on stable timer wakes
- 80%+ reduction in full-thread comment reloads after first task read
- no statistically meaningful regression in completion rate or failure rate
## Concrete Engineering Tasks
1. Add normalized usage fields and migration support for run analytics.
2. Patch sessioned adapter accounting to compute deltas from prior session totals.
3. Change `shouldResetTaskSessionForWake(...)` so timer wakes do not reset by default.
4. Implement `bootstrapPromptTemplate` end-to-end in adapter execution.
5. Add compact heartbeat context and incremental comment APIs.
6. Rewrite `skills/paperclip/SKILL.md` around delta-fetch behavior.
7. Add session rotation with carry-forward summaries.
8. Replace global skill injection with explicit allowlists.
## Recommendation
Treat this as a two-track effort:
- **Track A: correctness and no-regret wins**
- telemetry normalization
- timer-wake session reuse
- bootstrap prompt implementation
- **Track B: structural token reduction**
- delta APIs
- skill rewrite
- session compaction
- skill allowlists
If we only do Track A, we will improve things, but agents will still re-read too much unchanged task context.
If we only do Track B without fixing telemetry first, we will not be able to prove the gains cleanly.

File diff suppressed because it is too large Load Diff

View File

@@ -32,6 +32,23 @@ export const runningProcesses = new Map<string, RunningProcess>();
export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
export const MAX_EXCERPT_BYTES = 32 * 1024;
const SENSITIVE_ENV_KEY = /(key|token|secret|password|passwd|authorization|cookie)/i;
const PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES = [
"../../skills",
"../../../../../skills",
];
export interface PaperclipSkillEntry {
name: string;
source: string;
}
function normalizePathSlashes(value: string): string {
return value.replaceAll("\\", "/");
}
function isMaintainerOnlySkillTarget(candidate: string): boolean {
return normalizePathSlashes(candidate).includes("/.agents/skills/");
}
export function parseObject(value: unknown): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
@@ -245,6 +262,136 @@ export async function ensureAbsoluteDirectory(
}
}
export async function resolvePaperclipSkillsDir(
moduleDir: string,
additionalCandidates: string[] = [],
): Promise<string | null> {
const candidates = [
...PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES.map((relativePath) => path.resolve(moduleDir, relativePath)),
...additionalCandidates.map((candidate) => path.resolve(candidate)),
];
const seenRoots = new Set<string>();
for (const root of candidates) {
if (seenRoots.has(root)) continue;
seenRoots.add(root);
const isDirectory = await fs.stat(root).then((stats) => stats.isDirectory()).catch(() => false);
if (isDirectory) return root;
}
return null;
}
export async function listPaperclipSkillEntries(
moduleDir: string,
additionalCandidates: string[] = [],
): Promise<PaperclipSkillEntry[]> {
const root = await resolvePaperclipSkillsDir(moduleDir, additionalCandidates);
if (!root) return [];
try {
const entries = await fs.readdir(root, { withFileTypes: true });
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => ({
name: entry.name,
source: path.join(root, entry.name),
}));
} catch {
return [];
}
}
export async function readPaperclipSkillMarkdown(
moduleDir: string,
skillName: string,
): Promise<string | null> {
const normalized = skillName.trim().toLowerCase();
if (!normalized) return null;
const entries = await listPaperclipSkillEntries(moduleDir);
const match = entries.find((entry) => entry.name === normalized);
if (!match) return null;
try {
return await fs.readFile(path.join(match.source, "SKILL.md"), "utf8");
} catch {
return null;
}
}
export async function ensurePaperclipSkillSymlink(
source: string,
target: string,
linkSkill: (source: string, target: string) => Promise<void> = (linkSource, linkTarget) =>
fs.symlink(linkSource, linkTarget),
): Promise<"created" | "repaired" | "skipped"> {
const existing = await fs.lstat(target).catch(() => null);
if (!existing) {
await linkSkill(source, target);
return "created";
}
if (!existing.isSymbolicLink()) {
return "skipped";
}
const linkedPath = await fs.readlink(target).catch(() => null);
if (!linkedPath) return "skipped";
const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);
if (resolvedLinkedPath === source) {
return "skipped";
}
const linkedPathExists = await fs.stat(resolvedLinkedPath).then(() => true).catch(() => false);
if (linkedPathExists) {
return "skipped";
}
await fs.unlink(target);
await linkSkill(source, target);
return "repaired";
}
export async function removeMaintainerOnlySkillSymlinks(
skillsHome: string,
allowedSkillNames: Iterable<string>,
): Promise<string[]> {
const allowed = new Set(Array.from(allowedSkillNames));
try {
const entries = await fs.readdir(skillsHome, { withFileTypes: true });
const removed: string[] = [];
for (const entry of entries) {
if (allowed.has(entry.name)) continue;
const target = path.join(skillsHome, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (!existing?.isSymbolicLink()) continue;
const linkedPath = await fs.readlink(target).catch(() => null);
if (!linkedPath) continue;
const resolvedLinkedPath = path.isAbsolute(linkedPath)
? linkedPath
: path.resolve(path.dirname(target), linkedPath);
if (
!isMaintainerOnlySkillTarget(linkedPath) &&
!isMaintainerOnlySkillTarget(resolvedLinkedPath)
) {
continue;
}
await fs.unlink(target);
removed.push(entry.name);
}
return removed;
} catch {
return [];
}
}
export async function ensureCommandResolvable(command: string, cwd: string, env: NodeJS.ProcessEnv) {
const resolved = await resolveCommandPath(command, cwd, env);
if (resolved) return;

View File

@@ -13,17 +13,16 @@ import {
redactEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePaperclipSkillSymlink,
ensurePathInEnv,
listPaperclipSkillEntries,
removeMaintainerOnlySkillSymlinks,
renderTemplate,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const PAPERCLIP_SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../skills"), // published: <pkg>/dist/server/ -> <pkg>/skills/
path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/skills/
];
const CODEX_ROLLOUT_NOISE_RE =
/^\d{4}-\d{2}-\d{2}T[^\s]+\s+ERROR\s+codex_core::rollout::list:\s+state db missing rollout path for thread\s+[a-z0-9-]+$/i;
@@ -67,33 +66,42 @@ function codexHomeDir(): string {
return path.join(os.homedir(), ".codex");
}
async function resolvePaperclipSkillsDir(): Promise<string | null> {
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
}
return null;
}
type EnsureCodexSkillsInjectedOptions = {
skillsHome?: string;
skillsEntries?: Awaited<ReturnType<typeof listPaperclipSkillEntries>>;
linkSkill?: (source: string, target: string) => Promise<void>;
};
async function ensureCodexSkillsInjected(onLog: AdapterExecutionContext["onLog"]) {
const skillsDir = await resolvePaperclipSkillsDir();
if (!skillsDir) return;
export async function ensureCodexSkillsInjected(
onLog: AdapterExecutionContext["onLog"],
options: EnsureCodexSkillsInjectedOptions = {},
) {
const skillsEntries = options.skillsEntries ?? await listPaperclipSkillEntries(__moduleDir);
if (skillsEntries.length === 0) return;
const skillsHome = path.join(codexHomeDir(), "skills");
const skillsHome = options.skillsHome ?? path.join(codexHomeDir(), "skills");
await fs.mkdir(skillsHome, { recursive: true });
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const source = path.join(skillsDir, entry.name);
const removedSkills = await removeMaintainerOnlySkillSymlinks(
skillsHome,
skillsEntries.map((entry) => entry.name),
);
for (const skillName of removedSkills) {
await onLog(
"stderr",
`[paperclip] Removed maintainer-only Codex skill "${skillName}" from ${skillsHome}\n`,
);
}
const linkSkill = options.linkSkill;
for (const entry of skillsEntries) {
const target = path.join(skillsHome, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (existing) continue;
try {
await fs.symlink(source, target);
const result = await ensurePaperclipSkillSymlink(entry.source, target, linkSkill);
if (result === "skipped") continue;
await onLog(
"stderr",
`[paperclip] Injected Codex skill "${entry.name}" into ${skillsHome}\n`,
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Codex skill "${entry.name}" into ${skillsHome}\n`,
);
} catch (err) {
await onLog(

View File

@@ -1,4 +1,4 @@
export { execute } from "./execute.js";
export { execute, ensureCodexSkillsInjected } from "./execute.js";
export { testEnvironment } from "./test.js";
export { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";

View File

@@ -1,5 +1,4 @@
import fs from "node:fs/promises";
import type { Dirent } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -13,7 +12,10 @@ import {
redactEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePaperclipSkillSymlink,
ensurePathInEnv,
listPaperclipSkillEntries,
removeMaintainerOnlySkillSymlinks,
renderTemplate,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
@@ -23,10 +25,6 @@ import { normalizeCursorStreamLine } from "../shared/stream.js";
import { hasCursorTrustBypassArg } from "../shared/trust.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const PAPERCLIP_SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../skills"),
path.resolve(__moduleDir, "../../../../../skills"),
];
function firstNonEmptyLine(text: string): string {
return (
@@ -82,16 +80,9 @@ function cursorSkillsHome(): string {
return path.join(os.homedir(), ".cursor", "skills");
}
async function resolvePaperclipSkillsDir(): Promise<string | null> {
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
}
return null;
}
type EnsureCursorSkillsInjectedOptions = {
skillsDir?: string | null;
skillsEntries?: Array<{ name: string; source: string }>;
skillsHome?: string;
linkSkill?: (source: string, target: string) => Promise<void>;
};
@@ -100,8 +91,13 @@ export async function ensureCursorSkillsInjected(
onLog: AdapterExecutionContext["onLog"],
options: EnsureCursorSkillsInjectedOptions = {},
) {
const skillsDir = options.skillsDir ?? await resolvePaperclipSkillsDir();
if (!skillsDir) return;
const skillsEntries = options.skillsEntries
?? (options.skillsDir
? (await fs.readdir(options.skillsDir, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map((entry) => ({ name: entry.name, source: path.join(options.skillsDir!, entry.name) }))
: await listPaperclipSkillEntries(__moduleDir));
if (skillsEntries.length === 0) return;
const skillsHome = options.skillsHome ?? cursorSkillsHome();
try {
@@ -113,31 +109,26 @@ export async function ensureCursorSkillsInjected(
);
return;
}
let entries: Dirent[];
try {
entries = await fs.readdir(skillsDir, { withFileTypes: true });
} catch (err) {
const removedSkills = await removeMaintainerOnlySkillSymlinks(
skillsHome,
skillsEntries.map((entry) => entry.name),
);
for (const skillName of removedSkills) {
await onLog(
"stderr",
`[paperclip] Failed to read Paperclip skills from ${skillsDir}: ${err instanceof Error ? err.message : String(err)}\n`,
`[paperclip] Removed maintainer-only Cursor skill "${skillName}" from ${skillsHome}\n`,
);
return;
}
const linkSkill = options.linkSkill ?? ((source: string, target: string) => fs.symlink(source, target));
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const source = path.join(skillsDir, entry.name);
for (const entry of skillsEntries) {
const target = path.join(skillsHome, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (existing) continue;
try {
await linkSkill(source, target);
const result = await ensurePaperclipSkillSymlink(entry.source, target, linkSkill);
if (result === "skipped") continue;
await onLog(
"stderr",
`[paperclip] Injected Cursor skill "${entry.name}" into ${skillsHome}\n`,
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Cursor skill "${entry.name}" into ${skillsHome}\n`,
);
} catch (err) {
await onLog(

View File

@@ -12,7 +12,10 @@ import {
buildPaperclipEnv,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePaperclipSkillSymlink,
ensurePathInEnv,
listPaperclipSkillEntries,
removeMaintainerOnlySkillSymlinks,
parseObject,
redactEnvForLogs,
renderTemplate,
@@ -29,10 +32,6 @@ import {
import { firstNonEmptyLine } from "./utils.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const PAPERCLIP_SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../skills"),
path.resolve(__moduleDir, "../../../../../skills"),
];
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
const raw = env[key];
@@ -73,14 +72,6 @@ function renderApiAccessNote(env: Record<string, string>): string {
].join("\n");
}
async function resolvePaperclipSkillsDir(): Promise<string | null> {
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
}
return null;
}
function geminiSkillsHome(): string {
return path.join(os.homedir(), ".gemini", "skills");
}
@@ -93,8 +84,8 @@ function geminiSkillsHome(): string {
async function ensureGeminiSkillsInjected(
onLog: AdapterExecutionContext["onLog"],
): Promise<void> {
const skillsDir = await resolvePaperclipSkillsDir();
if (!skillsDir) return;
const skillsEntries = await listPaperclipSkillEntries(__moduleDir);
if (skillsEntries.length === 0) return;
const skillsHome = geminiSkillsHome();
try {
@@ -106,28 +97,27 @@ async function ensureGeminiSkillsInjected(
);
return;
}
let entries: Dirent[];
try {
entries = await fs.readdir(skillsDir, { withFileTypes: true });
} catch (err) {
const removedSkills = await removeMaintainerOnlySkillSymlinks(
skillsHome,
skillsEntries.map((entry) => entry.name),
);
for (const skillName of removedSkills) {
await onLog(
"stderr",
`[paperclip] Failed to read Paperclip skills from ${skillsDir}: ${err instanceof Error ? err.message : String(err)}\n`,
`[paperclip] Removed maintainer-only Gemini skill "${skillName}" from ${skillsHome}\n`,
);
return;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const source = path.join(skillsDir, entry.name);
for (const entry of skillsEntries) {
const target = path.join(skillsHome, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (existing) continue;
try {
await fs.symlink(source, target);
await onLog("stderr", `[paperclip] Linked Gemini skill: ${entry.name}\n`);
const result = await ensurePaperclipSkillSymlink(entry.source, target);
if (result === "skipped") continue;
await onLog(
"stderr",
`[paperclip] ${result === "repaired" ? "Repaired" : "Linked"} Gemini skill: ${entry.name}\n`,
);
} catch (err) {
await onLog(
"stderr",

View File

@@ -12,7 +12,10 @@ import {
redactEnvForLogs,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePaperclipSkillSymlink,
ensurePathInEnv,
listPaperclipSkillEntries,
removeMaintainerOnlySkillSymlinks,
renderTemplate,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
@@ -20,10 +23,6 @@ import { isPiUnknownSessionError, parsePiJsonl } from "./parse.js";
import { ensurePiModelConfiguredAndAvailable } from "./models.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const PAPERCLIP_SKILLS_CANDIDATES = [
path.resolve(__moduleDir, "../../skills"),
path.resolve(__moduleDir, "../../../../../skills"),
];
const PAPERCLIP_SESSIONS_DIR = path.join(os.homedir(), ".pi", "paperclips");
@@ -50,34 +49,32 @@ function parseModelId(model: string | null): string | null {
return trimmed.slice(trimmed.indexOf("/") + 1).trim() || null;
}
async function resolvePaperclipSkillsDir(): Promise<string | null> {
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
if (isDir) return candidate;
}
return null;
}
async function ensurePiSkillsInjected(onLog: AdapterExecutionContext["onLog"]) {
const skillsDir = await resolvePaperclipSkillsDir();
if (!skillsDir) return;
const skillsEntries = await listPaperclipSkillEntries(__moduleDir);
if (skillsEntries.length === 0) return;
const piSkillsHome = path.join(os.homedir(), ".pi", "agent", "skills");
await fs.mkdir(piSkillsHome, { recursive: true });
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const source = path.join(skillsDir, entry.name);
const removedSkills = await removeMaintainerOnlySkillSymlinks(
piSkillsHome,
skillsEntries.map((entry) => entry.name),
);
for (const skillName of removedSkills) {
await onLog(
"stderr",
`[paperclip] Removed maintainer-only Pi skill "${skillName}" from ${piSkillsHome}\n`,
);
}
for (const entry of skillsEntries) {
const target = path.join(piSkillsHome, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (existing) continue;
try {
await fs.symlink(source, target);
const result = await ensurePaperclipSkillSymlink(entry.source, target);
if (result === "skipped") continue;
await onLog(
"stderr",
`[paperclip] Injected Pi skill "${entry.name}" into ${piSkillsHome}\n`,
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Pi skill "${entry.name}" into ${piSkillsHome}\n`,
);
} catch (err) {
await onLog(

View File

@@ -17,6 +17,7 @@ type EmbeddedPostgresCtor = new (opts: {
password: string;
port: number;
persistent: boolean;
initdbFlags?: string[];
onLog?: (message: unknown) => void;
onError?: (message: unknown) => void;
}) => EmbeddedPostgresInstance;

View File

@@ -0,0 +1,61 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
listPaperclipSkillEntries,
removeMaintainerOnlySkillSymlinks,
} from "@paperclipai/adapter-utils/server-utils";
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
describe("paperclip skill utils", () => {
const cleanupDirs = new Set<string>();
afterEach(async () => {
await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));
cleanupDirs.clear();
});
it("lists runtime skills from ./skills without pulling in .agents/skills", async () => {
const root = await makeTempDir("paperclip-skill-roots-");
cleanupDirs.add(root);
const moduleDir = path.join(root, "a", "b", "c", "d", "e");
await fs.mkdir(moduleDir, { recursive: true });
await fs.mkdir(path.join(root, "skills", "paperclip"), { recursive: true });
await fs.mkdir(path.join(root, ".agents", "skills", "release"), { recursive: true });
const entries = await listPaperclipSkillEntries(moduleDir);
expect(entries.map((entry) => entry.name)).toEqual(["paperclip"]);
expect(entries[0]?.source).toBe(path.join(root, "skills", "paperclip"));
});
it("removes stale maintainer-only symlinks from a shared skills home", async () => {
const root = await makeTempDir("paperclip-skill-cleanup-");
cleanupDirs.add(root);
const skillsHome = path.join(root, "skills-home");
const runtimeSkill = path.join(root, "skills", "paperclip");
const customSkill = path.join(root, "custom", "release-notes");
const staleMaintainerSkill = path.join(root, ".agents", "skills", "release");
await fs.mkdir(skillsHome, { recursive: true });
await fs.mkdir(runtimeSkill, { recursive: true });
await fs.mkdir(customSkill, { recursive: true });
await fs.symlink(runtimeSkill, path.join(skillsHome, "paperclip"));
await fs.symlink(customSkill, path.join(skillsHome, "release-notes"));
await fs.symlink(staleMaintainerSkill, path.join(skillsHome, "release"));
const removed = await removeMaintainerOnlySkillSymlinks(skillsHome, ["paperclip"]);
expect(removed).toEqual(["release"]);
await expect(fs.lstat(path.join(skillsHome, "release"))).rejects.toThrow();
expect((await fs.lstat(path.join(skillsHome, "paperclip"))).isSymbolicLink()).toBe(true);
expect((await fs.lstat(path.join(skillsHome, "release-notes"))).isSymbolicLink()).toBe(true);
});
});