Dev -> Local #14
@@ -52,7 +52,7 @@ ARG USER_UID=1000
|
||||
ARG USER_GID=1000
|
||||
WORKDIR /app
|
||||
COPY --chown=node:node --from=build /app /app
|
||||
# Fork additions: kubectl, kubeseal, uv, forgejo CLIs, editor tools
|
||||
# Fork additions: kubectl, kubeseal, uv, forgejo CLIs, gitea tea CLI, editor tools
|
||||
# Upstream installs: claude-code, codex, opencode-ai, openssh-client, jq
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openssh-client jq nano vim \
|
||||
@@ -71,6 +71,8 @@ RUN apt-get update \
|
||||
&& chmod +x /usr/local/bin/fj-ex \
|
||||
&& curl -fsSL https://codeberg.org/romaintb/fgj/releases/download/v0.3.0/fgj_linux_amd64 -o /usr/local/bin/fgj \
|
||||
&& chmod +x /usr/local/bin/fgj \
|
||||
&& curl -fsSL https://dl.gitea.com/tea/0.14.0/tea-0.14.0-linux-amd64 -o /usr/local/bin/tea \
|
||||
&& chmod +x /usr/local/bin/tea \
|
||||
&& npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \
|
||||
&& mkdir -p /paperclip \
|
||||
&& chown node:node /paperclip
|
||||
|
||||
@@ -34,6 +34,7 @@ COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/
|
||||
COPY packages/plugins/sdk/package.json packages/plugins/sdk/
|
||||
COPY --parents packages/plugins/sandbox-providers/./*/package.json packages/plugins/sandbox-providers/
|
||||
COPY packages/plugins/paperclip-plugin-fake-sandbox/package.json packages/plugins/paperclip-plugin-fake-sandbox/
|
||||
COPY packages/plugins/plugin-llm-wiki/package.json packages/plugins/plugin-llm-wiki/
|
||||
COPY patches/ patches/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
scaffoldPluginProject: vi.fn((options: { outputDir: string }) => options.outputDir),
|
||||
}));
|
||||
|
||||
vi.mock("../../../packages/plugins/create-paperclip-plugin/src/index.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../../../packages/plugins/create-paperclip-plugin/src/index.js")>(
|
||||
"../../../packages/plugins/create-paperclip-plugin/src/index.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
scaffoldPluginProject: mocks.scaffoldPluginProject,
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
buildPluginInstallRequest,
|
||||
buildPluginInitNextCommands,
|
||||
buildPluginInitScaffoldOptions,
|
||||
registerPluginCommands,
|
||||
} from "../commands/client/plugin.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-plugin-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("plugin init", () => {
|
||||
beforeEach(() => {
|
||||
mocks.scaffoldPluginProject.mockClear();
|
||||
});
|
||||
|
||||
it("maps package name and flags to scaffolder options", () => {
|
||||
const cwd = path.resolve("/tmp/paperclip-cli-test");
|
||||
const options = buildPluginInitScaffoldOptions(
|
||||
"@acme/plugin-linear",
|
||||
{
|
||||
output: "plugins",
|
||||
template: "connector",
|
||||
category: "automation",
|
||||
displayName: "Linear Bridge",
|
||||
description: "Syncs Linear issues",
|
||||
author: "Acme",
|
||||
sdkPath: "../paperclip/packages/plugins/sdk",
|
||||
},
|
||||
cwd,
|
||||
);
|
||||
|
||||
expect(options).toEqual({
|
||||
pluginName: "@acme/plugin-linear",
|
||||
outputDir: path.resolve(cwd, "plugins", "plugin-linear"),
|
||||
template: "connector",
|
||||
category: "automation",
|
||||
displayName: "Linear Bridge",
|
||||
description: "Syncs Linear issues",
|
||||
author: "Acme",
|
||||
sdkPath: "../paperclip/packages/plugins/sdk",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds exact next commands using the scaffold path", () => {
|
||||
expect(buildPluginInitNextCommands("/tmp/acme plugin")).toEqual([
|
||||
"cd '/tmp/acme plugin'",
|
||||
"pnpm install",
|
||||
"pnpm dev",
|
||||
"paperclipai plugin install '/tmp/acme plugin'",
|
||||
]);
|
||||
});
|
||||
|
||||
it("registers the CLI wrapper and invokes the existing scaffolder", async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.configureOutput({ writeOut: () => {}, writeErr: () => {} });
|
||||
registerPluginCommands(program);
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
"plugin",
|
||||
"init",
|
||||
"demo-plugin",
|
||||
"--output",
|
||||
"/tmp/paperclip-init-output",
|
||||
"--template",
|
||||
"workspace",
|
||||
"--category",
|
||||
"workspace",
|
||||
"--display-name",
|
||||
"Demo Plugin",
|
||||
"--description",
|
||||
"Demo description",
|
||||
"--author",
|
||||
"Paperclip",
|
||||
"--sdk-path",
|
||||
"/repo/packages/plugins/sdk",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(mocks.scaffoldPluginProject).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.scaffoldPluginProject).toHaveBeenCalledWith({
|
||||
pluginName: "demo-plugin",
|
||||
outputDir: path.resolve("/tmp/paperclip-init-output", "demo-plugin"),
|
||||
template: "workspace",
|
||||
category: "workspace",
|
||||
displayName: "Demo Plugin",
|
||||
description: "Demo description",
|
||||
author: "Paperclip",
|
||||
sdkPath: "/repo/packages/plugins/sdk",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin install", () => {
|
||||
it("resolves an existing relative local path to an absolute local install request", () => {
|
||||
const cwd = makeTempDir();
|
||||
const pluginDir = path.join(cwd, "demo-plugin");
|
||||
fs.mkdirSync(pluginDir);
|
||||
|
||||
expect(buildPluginInstallRequest("demo-plugin", {}, { cwd })).toEqual({
|
||||
packageName: pluginDir,
|
||||
version: undefined,
|
||||
isLocalPath: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an absolute local path absolute and marks it as local", () => {
|
||||
const pluginDir = path.join(makeTempDir(), "demo-plugin");
|
||||
fs.mkdirSync(pluginDir);
|
||||
|
||||
expect(buildPluginInstallRequest(pluginDir, {}, { cwd: "/" })).toEqual({
|
||||
packageName: pluginDir,
|
||||
version: undefined,
|
||||
isLocalPath: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves npm package installs when no local path exists", () => {
|
||||
expect(
|
||||
buildPluginInstallRequest("@acme/plugin-linear", { version: "1.2.3" }, {
|
||||
cwd: makeTempDir(),
|
||||
}),
|
||||
).toEqual({
|
||||
packageName: "@acme/plugin-linear",
|
||||
version: "1.2.3",
|
||||
isLocalPath: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,11 @@
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { existsSync } from "node:fs";
|
||||
import { Command, Option } from "commander";
|
||||
import {
|
||||
scaffoldPluginProject,
|
||||
shellQuote,
|
||||
type ScaffoldPluginOptions,
|
||||
} from "../../../../packages/plugins/create-paperclip-plugin/src/index.js";
|
||||
import pc from "picocolors";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
@@ -39,28 +45,101 @@ interface PluginInstallOptions extends BaseClientOptions {
|
||||
version?: string;
|
||||
}
|
||||
|
||||
interface PluginInstallRequest {
|
||||
packageName: string;
|
||||
version?: string;
|
||||
isLocalPath: boolean;
|
||||
}
|
||||
|
||||
interface PluginUninstallOptions extends BaseClientOptions {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
interface PluginInitOptions extends BaseClientOptions {
|
||||
output?: string;
|
||||
template?: ScaffoldPluginOptions["template"];
|
||||
category?: ScaffoldPluginOptions["category"];
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
sdkPath?: string;
|
||||
}
|
||||
|
||||
interface PluginInitResult {
|
||||
outputDir: string;
|
||||
nextCommands: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function expandHomePath(packageArg: string): string {
|
||||
if (!packageArg.startsWith("~")) return packageArg;
|
||||
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
||||
return path.resolve(home, packageArg.slice(1).replace(/^[\\/]/, ""));
|
||||
}
|
||||
|
||||
function hasLocalPathSyntax(packageArg: string): boolean {
|
||||
return (
|
||||
path.isAbsolute(packageArg) ||
|
||||
packageArg.startsWith("./") ||
|
||||
packageArg.startsWith("../") ||
|
||||
packageArg.startsWith("~") ||
|
||||
packageArg.startsWith(".\\") ||
|
||||
packageArg.startsWith("..\\")
|
||||
);
|
||||
}
|
||||
|
||||
function isExistingRelativePath(
|
||||
packageArg: string,
|
||||
cwd: string,
|
||||
pathExists: (targetPath: string) => boolean,
|
||||
): boolean {
|
||||
if (packageArg.trim() === "") return false;
|
||||
if (hasLocalPathSyntax(packageArg)) return false;
|
||||
return pathExists(path.resolve(cwd, packageArg));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a local path argument to an absolute path so the server can find the
|
||||
* plugin on disk regardless of where the user ran the CLI.
|
||||
*/
|
||||
function resolvePackageArg(packageArg: string, isLocal: boolean): string {
|
||||
function resolvePackageArg(packageArg: string, isLocal: boolean, cwd = process.cwd()): string {
|
||||
if (!isLocal) return packageArg;
|
||||
// Already absolute
|
||||
if (path.isAbsolute(packageArg)) return packageArg;
|
||||
// Expand leading ~ to home directory
|
||||
if (packageArg.startsWith("~")) {
|
||||
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
||||
return path.resolve(home, packageArg.slice(1).replace(/^[\\/]/, ""));
|
||||
if (packageArg.startsWith("~")) return expandHomePath(packageArg);
|
||||
return path.resolve(cwd, packageArg);
|
||||
}
|
||||
|
||||
export function buildPluginInstallRequest(
|
||||
packageArg: string,
|
||||
opts: Pick<PluginInstallOptions, "local" | "version"> = {},
|
||||
deps: { cwd?: string; existsSync?: (targetPath: string) => boolean } = {},
|
||||
): PluginInstallRequest {
|
||||
const cwd = deps.cwd ?? process.cwd();
|
||||
const pathExists = deps.existsSync ?? existsSync;
|
||||
const isLocal =
|
||||
opts.local ||
|
||||
hasLocalPathSyntax(packageArg) ||
|
||||
(opts.version ? false : isExistingRelativePath(packageArg, cwd, pathExists));
|
||||
|
||||
if (isLocal && opts.version) {
|
||||
throw new Error("--version is only supported for npm package installs, not local plugin paths.");
|
||||
}
|
||||
return path.resolve(process.cwd(), packageArg);
|
||||
|
||||
return {
|
||||
packageName: resolvePackageArg(packageArg, Boolean(isLocal), cwd),
|
||||
version: opts.version,
|
||||
isLocalPath: Boolean(isLocal),
|
||||
};
|
||||
}
|
||||
|
||||
export function renderLocalPluginInstallHint(packagePath: string): string {
|
||||
return [
|
||||
pc.dim("Local plugin installs run trusted local code from your machine."),
|
||||
pc.dim(`Keep ${pc.cyan("pnpm dev")} running in ${packagePath}; Paperclip watches rebuilt dist output and reloads the plugin worker.`),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function formatPlugin(p: PluginRecord): string {
|
||||
@@ -87,6 +166,58 @@ function formatPlugin(p: PluginRecord): string {
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function packageToDirName(pluginName: string): string {
|
||||
return pluginName.replace(/^@[^/]+\//, "");
|
||||
}
|
||||
|
||||
export function buildPluginInitScaffoldOptions(
|
||||
packageName: string,
|
||||
opts: PluginInitOptions,
|
||||
cwd = process.cwd(),
|
||||
): ScaffoldPluginOptions {
|
||||
const outputRoot = path.resolve(cwd, opts.output ?? ".");
|
||||
const outputDir = path.resolve(outputRoot, packageToDirName(packageName));
|
||||
|
||||
return {
|
||||
pluginName: packageName,
|
||||
outputDir,
|
||||
template: opts.template,
|
||||
category: opts.category,
|
||||
displayName: opts.displayName,
|
||||
description: opts.description,
|
||||
author: opts.author,
|
||||
sdkPath: opts.sdkPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPluginInitNextCommands(outputDir: string): string[] {
|
||||
const quotedOutputDir = shellQuote(outputDir);
|
||||
return [
|
||||
`cd ${quotedOutputDir}`,
|
||||
"pnpm install",
|
||||
"pnpm dev",
|
||||
`paperclipai plugin install ${quotedOutputDir}`,
|
||||
];
|
||||
}
|
||||
|
||||
export function renderPluginInitSuccess(result: PluginInitResult): string {
|
||||
return [
|
||||
pc.green(`✓ Created plugin scaffold at ${result.outputDir}`),
|
||||
"",
|
||||
"Next commands:",
|
||||
...result.nextCommands.map((command) => ` ${pc.cyan(command)}`),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function runPluginInitCommand(packageName: string, opts: PluginInitOptions): PluginInitResult {
|
||||
const scaffoldOptions = buildPluginInitScaffoldOptions(packageName, opts);
|
||||
const outputDir = scaffoldPluginProject(scaffoldOptions);
|
||||
return {
|
||||
outputDir,
|
||||
nextCommands: buildPluginInitNextCommands(outputDir),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command registration
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -94,6 +225,43 @@ function formatPlugin(p: PluginRecord): string {
|
||||
export function registerPluginCommands(program: Command): void {
|
||||
const plugin = program.command("plugin").description("Plugin lifecycle management");
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin init <package-name>
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("init <packageName>")
|
||||
.description("Scaffold a local Paperclip plugin project")
|
||||
.option("--output <dir>", "Directory to create the plugin folder in")
|
||||
.addOption(
|
||||
new Option("--template <template>", "Starter template")
|
||||
.choices(["default", "connector", "workspace", "environment"])
|
||||
.default("default"),
|
||||
)
|
||||
.addOption(
|
||||
new Option("--category <category>", "Manifest category")
|
||||
.choices(["connector", "workspace", "automation", "ui", "environment"]),
|
||||
)
|
||||
.option("--display-name <name>", "Manifest display name")
|
||||
.option("--description <description>", "Manifest description")
|
||||
.option("--author <author>", "Manifest author")
|
||||
.option("--sdk-path <path>", "Local @paperclipai/plugin-sdk package path")
|
||||
.action((packageName: string, opts: PluginInitOptions) => {
|
||||
try {
|
||||
const result = runPluginInitCommand(packageName, opts);
|
||||
|
||||
if (opts.json) {
|
||||
printOutput(result, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(renderPluginInitSuccess(result));
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin list
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -147,31 +315,19 @@ export function registerPluginCommands(program: Command): void {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
|
||||
// Auto-detect local paths: starts with . or / or ~ or is an absolute path
|
||||
const isLocal =
|
||||
opts.local ||
|
||||
packageArg.startsWith("./") ||
|
||||
packageArg.startsWith("../") ||
|
||||
packageArg.startsWith("/") ||
|
||||
packageArg.startsWith("~");
|
||||
|
||||
const resolvedPackage = resolvePackageArg(packageArg, isLocal);
|
||||
const installRequest = buildPluginInstallRequest(packageArg, opts);
|
||||
|
||||
if (!ctx.json) {
|
||||
console.log(
|
||||
pc.dim(
|
||||
isLocal
|
||||
? `Installing plugin from local path: ${resolvedPackage}`
|
||||
: `Installing plugin: ${resolvedPackage}${opts.version ? `@${opts.version}` : ""}`,
|
||||
installRequest.isLocalPath
|
||||
? `Installing plugin from local path: ${installRequest.packageName}`
|
||||
: `Installing plugin: ${installRequest.packageName}${opts.version ? `@${opts.version}` : ""}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const installedPlugin = await ctx.api.post<PluginRecord>("/api/plugins/install", {
|
||||
packageName: resolvedPackage,
|
||||
version: opts.version,
|
||||
isLocalPath: isLocal,
|
||||
});
|
||||
const installedPlugin = await ctx.api.post<PluginRecord>("/api/plugins/install", installRequest);
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(installedPlugin, { json: true });
|
||||
@@ -192,6 +348,10 @@ export function registerPluginCommands(program: Command): void {
|
||||
if (installedPlugin.lastError) {
|
||||
console.log(pc.red(` Warning: ${installedPlugin.lastError}`));
|
||||
}
|
||||
|
||||
if (installRequest.isLocalPath) {
|
||||
console.log(renderLocalPluginInstallHint(installRequest.packageName));
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,5 +4,5 @@
|
||||
"outDir": "dist",
|
||||
"rootDir": ".."
|
||||
},
|
||||
"include": ["src", "../packages/shared/src"]
|
||||
"include": ["src", "../packages/shared/src", "../packages/plugins/create-paperclip-plugin/src"]
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ These decisions close open questions from `SPEC.md` for V1.
|
||||
| Visibility | Full visibility to board and all agents in same company |
|
||||
| Communication | Tasks + comments only (no separate chat system) |
|
||||
| Task ownership | Single assignee; atomic checkout required for `in_progress` transition |
|
||||
| Recovery | Liveness/watchdog recovery preserves explicit ownership: retry lost execution continuity where safe, otherwise create visible recovery issues or require human escalation (see `doc/execution-semantics.md`) |
|
||||
| Recovery | Liveness/watchdog recovery preserves explicit ownership: retry lost execution continuity where safe, otherwise open visible source-scoped recovery actions by default, use issue-backed recovery only for independent repair work, or require human escalation (see `doc/execution-semantics.md`) |
|
||||
| Agent adapters | Built-in `process`, `http`, local CLI/session adapters, and OpenClaw gateway support; external adapters can also be loaded through the adapter plugin flow |
|
||||
| Plugin framework | Local/self-hosted early plugin runtime is in scope; cloud marketplace and packaged public distribution remain out of scope |
|
||||
| Auth | Mode-dependent human auth (`local_trusted` implicit board in current code; authenticated mode uses sessions), API keys for agents |
|
||||
@@ -434,9 +434,10 @@ Side effects:
|
||||
V1 non-terminal liveness rule:
|
||||
|
||||
- agent-owned `todo`, `in_progress`, `in_review`, and `blocked` issues must have a live execution path, an explicit waiting path, or an explicit recovery path
|
||||
- `in_review` is healthy only when a typed execution participant, pending issue-thread interaction or approval, user owner, active run, queued wake, or explicit recovery issue owns the next action
|
||||
- `in_review` is healthy only when a typed execution participant, pending issue-thread interaction or approval, user owner, active run, queued wake, or explicit recovery action owns the next action
|
||||
- a blocked chain is covered only when each unresolved leaf issue is live or explicitly waiting
|
||||
- when Paperclip cannot safely infer the next action, it surfaces the problem through visible blocked/recovery work instead of silently completing or reassigning work
|
||||
- explicit recovery actions are the liveness primitive; source-scoped actions are the default form, issue-backed recovery is a fallback for independent repair work or safety boundaries, and comments alone are evidence rather than a healthy liveness path
|
||||
|
||||
Detailed ownership, execution, blocker, active-run watchdog, crash-recovery, and non-terminal liveness semantics are documented in `doc/execution-semantics.md`.
|
||||
|
||||
|
||||
+47
-20
@@ -156,7 +156,7 @@ If a parent is truly waiting on a child, model that with blockers. Do not rely o
|
||||
|
||||
For agent-owned, non-terminal issues, Paperclip should never leave work in a state where nobody is responsible for the next move and nothing will wake or surface it.
|
||||
|
||||
This is a visibility contract, not an auto-completion contract. If Paperclip cannot safely infer the next action, it should surface the ambiguity with a blocked state, a visible comment, or an explicit recovery issue. It must not silently mark work done from prose comments or guess that a dependency is complete.
|
||||
This is a visibility contract, not an auto-completion contract. If Paperclip cannot safely infer the next action, it should surface the ambiguity with a blocked state, a visible notice, or an explicit recovery action. It must not silently mark work done from prose comments or guess that a dependency is complete.
|
||||
|
||||
An issue is healthy when the product can answer "what moves this forward next?" without requiring a human to reconstruct intent from the whole thread. An issue is stalled when it is non-terminal but has no live execution path, no explicit waiting path, and no recovery path.
|
||||
|
||||
@@ -169,7 +169,32 @@ The valid action-path primitives are:
|
||||
- a one-shot issue monitor (`executionPolicy.monitor.nextCheckAt`) that will wake the assignee for a future check
|
||||
- a human owner via `assigneeUserId`
|
||||
- a first-class blocker chain whose unresolved leaf issues are themselves healthy
|
||||
- an open explicit recovery issue that names the owner and action needed to restore liveness
|
||||
- an open explicit recovery action that names the owner and action needed to restore liveness
|
||||
|
||||
### Explicit recovery actions
|
||||
|
||||
An explicit recovery action is a typed liveness repair path for a source issue. It is the recovery primitive; the action can be rendered directly on the source issue or backed by a separate recovery issue when the repair needs its own work item.
|
||||
|
||||
A valid recovery action must name:
|
||||
|
||||
- the source issue and company
|
||||
- the recovery kind and idempotency fingerprint
|
||||
- the recovery owner, plus previous or return owner when ownership may temporarily shift
|
||||
- the cause, bounded evidence, and next action
|
||||
- the wake, monitor, timeout, retry, or escalation policy that will move the action forward
|
||||
- the resolution outcome when closed, such as restored, delegated, false positive, blocked, escalated, or cancelled
|
||||
|
||||
A source-scoped recovery action is the default form. Use it when the next safe move is to repair the source issue's liveness directly: restore a wake path, clarify disposition, re-establish a monitor, record a false positive, or delegate real follow-up work from the source issue.
|
||||
|
||||
Use an issue-backed recovery action only when the recovery is genuinely independent work or when source-scoped handling would be unsafe or unclear. Examples include:
|
||||
|
||||
- long or cross-agent repair work with its own assignee, subtasks, or blockers
|
||||
- real delegated follow-up that should block the source issue as a first-class dependency
|
||||
- active-run watchdog work that must observe a still-running source process without interfering with it
|
||||
- recovery that needs separate review, approval, security handling, or escalation ownership
|
||||
- cases where source issue ownership cannot be changed or restored safely
|
||||
|
||||
A comment or system notice can be evidence for a recovery action, but it is not a recovery action by itself. Comment-only recovery is not a healthy liveness path because it does not define a typed owner, wake or monitor policy, retry bound, timeout, escalation path, or resolution outcome.
|
||||
|
||||
### Agent-assigned `todo`
|
||||
|
||||
@@ -191,7 +216,7 @@ Assigning an issue normally implies executable intent. When create APIs receive
|
||||
|
||||
An explicit assigned `backlog` issue remains valid when the creator is deliberately parking the work. It must not wake the assignee just because it has an assignee. Paperclip should make that choice visible in activity and UI so operators can distinguish intentional parking from a missed handoff.
|
||||
|
||||
An assigned `backlog` issue becomes a liveness problem when another issue is blocked on it and there is no explicit waiting path such as a human owner, active run, queued wake, pending interaction or approval, monitor, or open recovery issue. In that case the blocked parent should surface "blocked by parked work" rather than treating the dependency chain as healthy.
|
||||
An assigned `backlog` issue becomes a liveness problem when another issue is blocked on it and there is no explicit waiting path such as a human owner, active run, queued wake, pending interaction or approval, monitor, or open recovery action. In that case the blocked parent should surface "blocked by parked work" rather than treating the dependency chain as healthy.
|
||||
|
||||
### Agent-assigned `in_progress`
|
||||
|
||||
@@ -202,7 +227,7 @@ A healthy active-work state means at least one of these is true:
|
||||
- there is an active run for the issue
|
||||
- there is already a queued continuation wake
|
||||
- there is an active one-shot monitor that will wake the assignee for a future check
|
||||
- there is an open explicit recovery issue for the lost execution path
|
||||
- there is an open explicit recovery action for the lost execution path
|
||||
|
||||
An agent-owned `in_progress` issue is stalled when it has no active run, no queued continuation, and no explicit recovery surface. A still-running but silent process is not automatically stalled; it is handled by the active-run watchdog contract.
|
||||
|
||||
@@ -217,11 +242,11 @@ A healthy `in_review` issue has at least one valid action path:
|
||||
- a human owner via `assigneeUserId`
|
||||
- an active run or queued wake that is expected to process the review state
|
||||
- an active one-shot monitor for an external service or async review loop that the assignee owns
|
||||
- an open explicit recovery issue for an ambiguous review handoff
|
||||
- an open explicit recovery action for an ambiguous review handoff
|
||||
|
||||
Agent-assigned `in_review` with no typed participant is only healthy when one of the other paths exists. Assignment to the same agent that produced the handoff is not, by itself, a review path.
|
||||
|
||||
An `in_review` issue is stalled when it has no typed participant, no pending interaction or approval, no user owner, no active monitor, no active run, no queued wake, and no explicit recovery issue. Paperclip should surface that state as recovery work rather than silently completing the issue or leaving blocker chains parked indefinitely.
|
||||
An `in_review` issue is stalled when it has no typed participant, no pending interaction or approval, no user owner, no active monitor, no active run, no queued wake, and no explicit recovery action. Paperclip should surface that state as recovery work rather than silently completing the issue or leaving blocker chains parked indefinitely.
|
||||
|
||||
### Issue monitors
|
||||
|
||||
@@ -241,7 +266,7 @@ Monitors are not recurring intervals. When a monitor fires, Paperclip clears the
|
||||
|
||||
Because `serviceName` and `notes` remain visible in issue activity and wake context, operators should keep them short and non-secret. Put enough context for the assignee to know what to inspect, but do not include signed URLs, bearer tokens, customer secrets, tenant-private identifiers, or provider links with embedded credentials.
|
||||
|
||||
Monitor bounds are enforced. Paperclip rejects attempts to re-arm a monitor whose `timeoutAt` or `maxAttempts` is already exhausted. When a scheduled monitor reaches an exhausted bound at trigger time, Paperclip clears it and follows `recoveryPolicy`: `wake_owner` queues a bounded recovery wake for the assignee, `create_recovery_issue` opens visible recovery work, and `escalate_to_board` records a board-visible escalation comment/activity.
|
||||
Monitor bounds are enforced. Paperclip rejects attempts to re-arm a monitor whose `timeoutAt` or `maxAttempts` is already exhausted. When a scheduled monitor reaches an exhausted bound at trigger time, Paperclip clears it and follows `recoveryPolicy`: `wake_owner` queues a bounded recovery wake for the assignee, `create_recovery_issue` opens visible issue-backed recovery work, and `escalate_to_board` records a board-visible escalation comment/activity.
|
||||
|
||||
Use `blocked` instead of a monitor when no Paperclip assignee owns a responsible polling path. In that case, name the external owner/action or create first-class recovery/blocker work.
|
||||
|
||||
@@ -252,12 +277,12 @@ This is explicit waiting state.
|
||||
A healthy `blocked` issue has an explicit waiting path:
|
||||
|
||||
- first-class blockers exist, and each unresolved leaf has a valid action path under this contract
|
||||
- the issue is blocked on an explicit recovery issue that itself has a live or waiting path
|
||||
- the issue has an explicit recovery action that itself has a live or waiting path
|
||||
- the issue is waiting on a pending interaction, linked approval, human owner, or clearly named external owner/action
|
||||
|
||||
A blocker chain is covered only when its unresolved leaf is live or explicitly waiting. An intermediate `blocked` issue does not make the chain healthy by itself.
|
||||
|
||||
A `blocked` issue is stalled when the unresolved blocker leaf has no active run, queued wake, typed participant, pending interaction or approval, user owner, external owner/action, or recovery issue. In that case the parent should show the first stalled leaf instead of presenting the dependency as calmly covered.
|
||||
A `blocked` issue is stalled when the unresolved blocker leaf has no active run, queued wake, typed participant, pending interaction or approval, user owner, external owner/action, or recovery action. In that case the parent should show the first stalled leaf instead of presenting the dependency as calmly covered.
|
||||
|
||||
## 8. Crash and Restart Recovery
|
||||
|
||||
@@ -277,7 +302,7 @@ Example:
|
||||
Recovery rule:
|
||||
|
||||
- if the latest issue-linked run failed/timed out/cancelled and no live execution path remains, Paperclip queues one automatic assignment recovery wake
|
||||
- if that recovery wake also finishes and the issue is still stranded, Paperclip moves the issue to `blocked` and posts a visible comment
|
||||
- if that recovery wake also finishes and the issue is still stranded, Paperclip moves the issue to `blocked` and opens or updates an explicit recovery action when a bounded owner/action is known; the visible comment is evidence, not the recovery path by itself
|
||||
|
||||
This is a dispatch recovery, not a continuation recovery.
|
||||
|
||||
@@ -293,7 +318,7 @@ Example:
|
||||
Recovery rule:
|
||||
|
||||
- Paperclip queues one automatic continuation wake
|
||||
- if that continuation wake also finishes and the issue is still stranded, Paperclip moves the issue to `blocked` and posts a visible comment
|
||||
- if that continuation wake also finishes and the issue is still stranded, Paperclip moves the issue to `blocked` and opens or updates an explicit recovery action when a bounded owner/action is known; the visible comment is evidence, not the recovery path by itself
|
||||
|
||||
This is an active-work continuity recovery.
|
||||
|
||||
@@ -306,7 +331,7 @@ On startup and on the periodic recovery loop, Paperclip now does four things in
|
||||
1. reap orphaned `running` runs
|
||||
2. resume persisted `queued` runs
|
||||
3. reconcile stranded assigned work
|
||||
4. scan silent active runs and create or update explicit watchdog review issues
|
||||
4. scan silent active runs and create or update explicit watchdog recovery actions
|
||||
|
||||
The stranded-work pass closes the gap where issue state survives a crash but the wake/run path does not. The silent-run scan covers the separate case where a live process exists but has stopped producing observable output.
|
||||
|
||||
@@ -319,11 +344,11 @@ The recovery service owns this contract:
|
||||
- classify active-run output silence as `ok`, `suspicious`, `critical`, `snoozed`, or `not_applicable`
|
||||
- collect bounded evidence from run logs, recent run events, child issues, and blockers
|
||||
- preserve redaction and truncation before evidence is written to issue descriptions
|
||||
- create at most one open `stale_active_run_evaluation` issue per run
|
||||
- create at most one open watchdog recovery action per run; issue-backed implementations use `stale_active_run_evaluation` issues
|
||||
- honor active snooze decisions before creating more review work
|
||||
- build the `outputSilence` summary shown by live-run and active-run API responses
|
||||
|
||||
Suspicious silence creates a medium-priority review issue for the selected recovery owner. Critical silence raises that review issue to high priority and blocks the source issue on the explicit evaluation task without cancelling the active process.
|
||||
Suspicious silence creates a medium-priority watchdog recovery action for the selected recovery owner. Critical silence raises that recovery action to high priority and, when issue-backed evaluation is needed for correctness, blocks the source issue on the explicit evaluation task without cancelling the active process.
|
||||
|
||||
Watchdog decisions are explicit operator/recovery-owner decisions:
|
||||
|
||||
@@ -333,7 +358,7 @@ Watchdog decisions are explicit operator/recovery-owner decisions:
|
||||
|
||||
Operators should prefer `snooze` for known time-bounded quiet periods. `continue` is only a short acknowledgement of the current evidence; if the run remains silent after the re-arm window, the periodic watchdog scan can create or update review work again.
|
||||
|
||||
The board can record watchdog decisions. The assigned owner of the watchdog evaluation issue can also record them. Other agents cannot.
|
||||
The board can record watchdog decisions. The assigned owner of an issue-backed watchdog evaluation can also record them. Other agents cannot.
|
||||
|
||||
## 11. Auto-Recover vs Explicit Recovery vs Human Escalation
|
||||
|
||||
@@ -351,9 +376,9 @@ Examples:
|
||||
|
||||
Auto-recovery preserves the existing owner. It does not choose a replacement agent.
|
||||
|
||||
### Explicit Recovery Issue
|
||||
### Explicit Recovery Action
|
||||
|
||||
Paperclip creates an explicit recovery issue when the system can identify a problem but cannot safely complete the work itself.
|
||||
Paperclip opens an explicit recovery action when the system can identify a problem but cannot safely complete the work itself.
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -361,9 +386,11 @@ Examples:
|
||||
- a dependency graph has an invalid/uninvokable owner, unassigned blocker, or invalid review participant
|
||||
- an active run is silent past the watchdog threshold
|
||||
|
||||
The source issue remains visible and blocked on the recovery issue when blocking is necessary for correctness. The recovery owner must restore a live path, resolve the source issue manually, or record the reason it is a false positive.
|
||||
The recovery action stays source-scoped by default. The source issue should show the recovery owner, cause, evidence, next action, and wake or monitor policy in its own thread/detail surface.
|
||||
|
||||
Instance-level issue-graph liveness auto-recovery is disabled by default. When enabled, its lookback window means "dependency paths updated within the last N hours"; older findings remain advisory and are counted as outside the configured lookback instead of creating recovery issues automatically. This is an operator noise control, not the older staleness delay for determining whether a chain is old enough to surface.
|
||||
Create an issue-backed recovery action only when a separate issue is the right execution object. In that fallback form, the source issue remains visible and is blocked on the recovery issue when blocking is necessary for correctness. The recovery owner must restore a live path, resolve the source issue manually, delegate real follow-up work, or record the reason the signal is a false positive.
|
||||
|
||||
Instance-level issue-graph liveness auto-recovery is disabled by default. When enabled, its lookback window means "dependency paths updated within the last N hours"; older findings remain advisory and are counted as outside the configured lookback instead of creating recovery actions automatically. This is an operator noise control, not the older staleness delay for determining whether a chain is old enough to surface.
|
||||
|
||||
### Human Escalation
|
||||
|
||||
@@ -391,7 +418,7 @@ The recovery model is intentionally conservative:
|
||||
|
||||
- preserve ownership
|
||||
- retry once when the control plane lost execution continuity
|
||||
- create explicit recovery work when the system can identify a bounded recovery owner/action
|
||||
- open an explicit recovery action when the system can identify a bounded recovery owner/action
|
||||
- escalate visibly when the system cannot safely keep going
|
||||
|
||||
## 13. Practical Interpretation
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# Local Plugin Development
|
||||
|
||||
This is the short happy-path guide for developing a Paperclip plugin from a folder on your machine. You will scaffold a plugin, run it in watch mode, install it into a running Paperclip instance from an absolute local path, and edit code with the plugin worker reloading after each rebuild.
|
||||
|
||||
For the full alpha surface — manifest fields, capabilities, managed agents/projects/routines, UI slots, scoped API routes — see [`PLUGIN_AUTHORING_GUIDE.md`](./PLUGIN_AUTHORING_GUIDE.md).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 22+ and `pnpm`.
|
||||
- A local Paperclip checkout you can run from source. Local plugin installs read source from disk, so the running server must be able to see the path you give it.
|
||||
|
||||
## The five steps
|
||||
|
||||
```bash
|
||||
# 1. Start Paperclip locally
|
||||
pnpm paperclipai run
|
||||
|
||||
# 2. Scaffold a plugin outside the Paperclip repo
|
||||
paperclipai plugin init @acme/hello-plugin --output ~/dev/paperclip-plugins
|
||||
|
||||
# 3. Install dependencies and start the watch build
|
||||
cd ~/dev/paperclip-plugins/hello-plugin
|
||||
pnpm install
|
||||
pnpm dev
|
||||
|
||||
# 4. In another terminal, install the plugin from its absolute path
|
||||
paperclipai plugin install ~/dev/paperclip-plugins/hello-plugin
|
||||
|
||||
# 5. Confirm it loaded
|
||||
paperclipai plugin list
|
||||
paperclipai plugin inspect acme.hello-plugin
|
||||
```
|
||||
|
||||
That's the loop. The rest of this page explains what each step does and what to expect when you edit code.
|
||||
|
||||
### 1. Start Paperclip
|
||||
|
||||
```bash
|
||||
pnpm paperclipai run
|
||||
```
|
||||
|
||||
Paperclip listens on `http://127.0.0.1:3100` by default. The CLI talks to that server, so leave it running.
|
||||
|
||||
### 2. Scaffold the plugin
|
||||
|
||||
```bash
|
||||
paperclipai plugin init @acme/hello-plugin --output ~/dev/paperclip-plugins
|
||||
```
|
||||
|
||||
This creates `~/dev/paperclip-plugins/hello-plugin/` with `src/manifest.ts`, `src/worker.ts`, `src/ui/index.tsx`, an esbuild watch config, a Vitest config, and a snapshot of `@paperclipai/plugin-sdk` from your local Paperclip checkout. You can run the package and tests without publishing anything to npm.
|
||||
|
||||
Useful flags:
|
||||
|
||||
- `--template <default|connector|workspace|environment>` — starter shape.
|
||||
- `--category <connector|workspace|automation|ui|environment>` — manifest category.
|
||||
- `--display-name`, `--description`, `--author` — manifest metadata.
|
||||
- `--sdk-path <absolute-path>` — point at a specific `packages/plugins/sdk` checkout if you have more than one.
|
||||
|
||||
When `plugin init` finishes, it prints the next four commands literally. You can copy them.
|
||||
|
||||
### 3. Install dependencies and run the watch build
|
||||
|
||||
```bash
|
||||
cd ~/dev/paperclip-plugins/hello-plugin
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
`pnpm dev` runs `esbuild --watch` against the plugin source and emits `dist/manifest.js`, `dist/worker.js`, and `dist/ui/`. Leave it running. Every time you save, esbuild rebuilds the affected output file.
|
||||
|
||||
If your plugin has UI and you want a browser-side dev server with hot module replacement during local UI iteration, run `pnpm dev:ui` in a second terminal. It serves `dist/ui/` on `http://127.0.0.1:4177`. This is optional; Paperclip can load the built UI directly from `dist/ui/` without it.
|
||||
|
||||
### 4. Install from the absolute path
|
||||
|
||||
```bash
|
||||
paperclipai plugin install ~/dev/paperclip-plugins/hello-plugin
|
||||
```
|
||||
|
||||
The CLI auto-detects local paths (anything that looks absolute, starts with `./`, `../`, or `~`, or resolves to an existing folder relative to the current directory) and sends `{ isLocalPath: true }` to `POST /api/plugins/install` with the resolved absolute path. If you want to be explicit, pass `--local`.
|
||||
|
||||
You will see a confirmation like:
|
||||
|
||||
```
|
||||
Installing plugin from local path: /Users/you/dev/paperclip-plugins/hello-plugin
|
||||
✓ Installed acme.hello-plugin v0.1.0 (ready)
|
||||
Local plugin installs run trusted local code from your machine.
|
||||
Keep `pnpm dev` running in /Users/you/dev/paperclip-plugins/hello-plugin;
|
||||
Paperclip watches rebuilt dist output and reloads the plugin worker.
|
||||
```
|
||||
|
||||
Relative paths are resolved against the current working directory, so `paperclipai plugin install .` from inside the plugin folder works too.
|
||||
|
||||
### 5. Inspect
|
||||
|
||||
```bash
|
||||
paperclipai plugin list
|
||||
paperclipai plugin inspect acme.hello-plugin
|
||||
```
|
||||
|
||||
`list` shows plugin key, status, version, and short error. `inspect` prints the same record with the full last error if there is one. Both accept `--json` if you want to script against them.
|
||||
|
||||
## Reload semantics, honestly
|
||||
|
||||
Paperclip watches the on-disk plugin package after a local install. The watcher targets the runtime entrypoints declared in the package's `paperclipPlugin` field (`dist/manifest.js`, `dist/worker.js`, `dist/ui/`).
|
||||
|
||||
What that means in practice:
|
||||
|
||||
- **Worker code:** save a `.ts` file → esbuild rewrites `dist/worker.js` → Paperclip debounces ~500ms and restarts the plugin worker. The next worker call uses the new code. There is no in-process hot module replacement for worker code; it is a worker restart.
|
||||
- **Manifest:** save `src/manifest.ts` → `dist/manifest.js` rewrites → the worker restarts and the host re-reads the manifest.
|
||||
- **Plugin UI:** save a `.tsx` file → esbuild rewrites `dist/ui/` → Paperclip reloads the UI bundle on its next mount. To get HMR during UI iteration, run `pnpm dev:ui` and point at the dev server with `devUiUrl` in your manifest while developing.
|
||||
- **Without `pnpm dev`:** the watcher only fires on `dist/*` changes. If you stop the watch build, source edits do not reach Paperclip. Restart `pnpm dev` (or run `pnpm build` once) before expecting changes.
|
||||
- **`node_modules`, `.git`, `.paperclip-sdk`, and other dotfolders are ignored.** Adding a dependency requires the new code to actually be imported and rebuilt before the worker sees it.
|
||||
|
||||
The server never compiles plugin source for you. The package's own build scripts own that step.
|
||||
|
||||
## Local path plugins vs npm packages
|
||||
|
||||
Both go through the same install endpoint, but they mean different things:
|
||||
|
||||
- **Local path plugins are trusted local code.** Paperclip executes worker code from disk under the same trust boundary as the rest of the running instance. This is meant for developing or operating a plugin against a checkout you control. There is no signature check, no sandboxing of worker code, and no provenance metadata beyond the path. Do not install local-path plugins you did not write.
|
||||
- **npm packages are the deployable artifact.** `paperclipai plugin install @acme/plugin-foo` (optionally `--version 1.2.3`) installs from your configured npm registry, version-pins, and produces an install record that other operators can reproduce. Ship plugins this way.
|
||||
|
||||
When you are done iterating locally, publish the package and reinstall the npm-package form so the install reflects what you will ship.
|
||||
|
||||
## Common things to do next
|
||||
|
||||
- **Restart cleanly:** `paperclipai plugin disable <key>` pauses the plugin without removing it. `paperclipai plugin enable <key>` brings it back. `paperclipai plugin uninstall <key>` removes the install record; add `--force` to also purge plugin state and settings.
|
||||
- **Browse examples:** `paperclipai plugin examples` lists the bundled example plugins that ship with the repo, each with a ready-to-run `paperclipai plugin install <path>` line.
|
||||
- **Go deeper:** [`PLUGIN_AUTHORING_GUIDE.md`](./PLUGIN_AUTHORING_GUIDE.md) covers worker capabilities, managed agents/projects/routines, plugin database namespaces, scoped API routes, and the shared UI components in `@paperclipai/plugin-sdk/ui`. [`PLUGIN_SPEC.md`](./PLUGIN_SPEC.md) is the longer-form specification, including future ideas that are not yet implemented.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`Plugin install returned no plugin record` or `error` status.** Run `paperclipai plugin inspect <key>` for the last error. The most common causes are (1) the plugin has not built yet — run `pnpm dev` or `pnpm build` first, (2) the `paperclipPlugin` entries in `package.json` point at files that do not exist on disk, or (3) the manifest failed validation. The Paperclip server log has the full validation error.
|
||||
- **Edits do not seem to reload.** Confirm `pnpm dev` is still running and writing to `dist/`. If you renamed entry files, update the `paperclipPlugin.manifest` / `paperclipPlugin.worker` / `paperclipPlugin.ui` fields in `package.json` so the watcher targets them.
|
||||
- **Worker restarts but UI is stale.** Hard-reload the page. If you want HMR, run `pnpm dev:ui` and set `devUiUrl` in your manifest to `http://127.0.0.1:4177` during development.
|
||||
- **Path arguments fail on Windows.** Quote paths that contain spaces, and prefer absolute paths over `~`-prefixed paths in non-bash shells.
|
||||
@@ -4,6 +4,8 @@ This guide describes the current, implemented way to create a Paperclip plugin i
|
||||
|
||||
It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec includes future ideas; this guide only covers the alpha surface that exists now.
|
||||
|
||||
> **New to plugins?** Start with the short [Local Plugin Development guide](./LOCAL_PLUGIN_DEVELOPMENT.md) — it walks the CLI happy path (`plugin init` → `pnpm dev` → `plugin install <path>`) end to end. Come back here for the full manifest surface, worker capabilities, and UI components.
|
||||
|
||||
## Current reality
|
||||
|
||||
- Treat plugin workers and plugin UI as trusted code.
|
||||
@@ -20,23 +22,13 @@ It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec i
|
||||
|
||||
## Scaffold a plugin
|
||||
|
||||
Use the scaffold package:
|
||||
Use the CLI scaffold command:
|
||||
|
||||
```bash
|
||||
pnpm --filter @paperclipai/create-paperclip-plugin build
|
||||
node packages/plugins/create-paperclip-plugin/dist/index.js @yourscope/plugin-name --output ./packages/plugins/examples
|
||||
paperclipai plugin init @yourscope/plugin-name --output /absolute/path/to/plugin-repos
|
||||
```
|
||||
|
||||
For a plugin that lives outside the Paperclip repo:
|
||||
|
||||
```bash
|
||||
pnpm --filter @paperclipai/create-paperclip-plugin build
|
||||
node packages/plugins/create-paperclip-plugin/dist/index.js @yourscope/plugin-name \
|
||||
--output /absolute/path/to/plugin-repos \
|
||||
--sdk-path /absolute/path/to/paperclip/packages/plugins/sdk
|
||||
```
|
||||
|
||||
That creates a package with:
|
||||
That creates `<output>/plugin-name/` with:
|
||||
|
||||
- `src/manifest.ts`
|
||||
- `src/worker.ts`
|
||||
@@ -47,11 +39,13 @@ That creates a package with:
|
||||
|
||||
Inside this monorepo, the scaffold uses `workspace:*` for `@paperclipai/plugin-sdk`.
|
||||
|
||||
Outside this monorepo, the scaffold snapshots `@paperclipai/plugin-sdk` from the local Paperclip checkout into a `.paperclip-sdk/` tarball so you can build and test a plugin without publishing anything to npm first.
|
||||
Outside this monorepo, the scaffold snapshots `@paperclipai/plugin-sdk` from the local Paperclip checkout into a `.paperclip-sdk/` tarball so you can build and test a plugin without publishing anything to npm first. Pass `--sdk-path /absolute/path/to/paperclip/packages/plugins/sdk` if you have more than one Paperclip checkout.
|
||||
|
||||
## Recommended local workflow
|
||||
## Local development workflow
|
||||
|
||||
From the generated plugin folder:
|
||||
See the short [Local Plugin Development guide](./LOCAL_PLUGIN_DEVELOPMENT.md) for the full happy path (`pnpm dev` → `paperclipai plugin install <absolute-path>` → `paperclipai plugin list`) and reload semantics.
|
||||
|
||||
Minimum verification from the generated plugin folder:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
@@ -60,16 +54,6 @@ pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
For local development, install it into Paperclip from an absolute local path through the plugin manager or API. The server supports local filesystem installs and watches local-path plugins for file changes so worker restarts happen automatically after rebuilds.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:3100/api/plugins/install \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"packageName":"/absolute/path/to/your-plugin","isLocalPath":true}'
|
||||
```
|
||||
|
||||
## Supported alpha surface
|
||||
|
||||
Worker:
|
||||
|
||||
@@ -3,9 +3,17 @@ import { buildSandboxNpmInstallCommand } from "./sandbox-install-command.js";
|
||||
|
||||
describe("buildSandboxNpmInstallCommand", () => {
|
||||
it("installs globally as root, via sudo when available, and under ~/.local otherwise", () => {
|
||||
expect(buildSandboxNpmInstallCommand("@google/gemini-cli")).toBe(
|
||||
'if [ "$(id -u)" -eq 0 ]; then npm install -g \'@google/gemini-cli\'; elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then sudo -E npm install -g \'@google/gemini-cli\'; else mkdir -p "$HOME/.local" && npm install -g --prefix "$HOME/.local" \'@google/gemini-cli\'; fi',
|
||||
);
|
||||
const command = buildSandboxNpmInstallCommand("@google/gemini-cli");
|
||||
expect(command).toContain("if [ \"$(id -u)\" -eq 0 ]; then npm install -g '@google/gemini-cli';");
|
||||
expect(command).toContain("sudo -E npm install -g '@google/gemini-cli'");
|
||||
expect(command).toContain("npm install -g --prefix \"$HOME/.local\" '@google/gemini-cli'");
|
||||
});
|
||||
|
||||
it("bootstraps npm from a portable Node tarball when missing", () => {
|
||||
const command = buildSandboxNpmInstallCommand("@google/gemini-cli");
|
||||
expect(command).toContain("if ! command -v npm >/dev/null 2>&1; then");
|
||||
expect(command).toContain("https://nodejs.org/dist/");
|
||||
expect(command).toContain('export PATH="$HOME/.local/bin:$PATH"');
|
||||
});
|
||||
|
||||
it("shell-quotes package names", () => {
|
||||
|
||||
@@ -2,10 +2,40 @@ function shellSingleQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", `'\"'\"'`)}'`;
|
||||
}
|
||||
|
||||
// Bootstrap a usable npm when the sandbox image ships without one (e.g. the
|
||||
// default exe.dev VM image has sshd + a normal user homedir but no Node
|
||||
// toolchain). We install a portable Node tarball into $HOME/.local rather
|
||||
// than using apt-get because the distro-packaged Node is often old enough to
|
||||
// reject modern JS syntax (regex /v flag, etc.) used by adapter CLIs like
|
||||
// @google/gemini-cli. The bootstrap also sets PAPERCLIP_NPM_BOOTSTRAPPED=1
|
||||
// so the install step knows to skip sudo — sudo would reset PATH via
|
||||
// secure_path and lose visibility of the freshly-installed npm in
|
||||
// $HOME/.local/bin.
|
||||
const ENSURE_NPM_PREAMBLE =
|
||||
"PAPERCLIP_NPM_BOOTSTRAPPED=; " +
|
||||
'if ! command -v npm >/dev/null 2>&1; then ' +
|
||||
'NODE_ARCH="$(uname -m)"; ' +
|
||||
'case "$NODE_ARCH" in ' +
|
||||
"x86_64) NODE_ARCH=x64 ;; " +
|
||||
"aarch64|arm64) NODE_ARCH=arm64 ;; " +
|
||||
"esac; " +
|
||||
'NODE_VERSION="v22.11.0"; ' +
|
||||
'NODE_TARBALL="node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz"; ' +
|
||||
'mkdir -p "$HOME/.local"; ' +
|
||||
'curl -fsSL "https://nodejs.org/dist/${NODE_VERSION}/${NODE_TARBALL}" -o "/tmp/${NODE_TARBALL}" && ' +
|
||||
'tar -xJf "/tmp/${NODE_TARBALL}" -C "$HOME/.local" --strip-components=1 && ' +
|
||||
'rm -f "/tmp/${NODE_TARBALL}" && ' +
|
||||
'export PATH="$HOME/.local/bin:$PATH" && ' +
|
||||
"PAPERCLIP_NPM_BOOTSTRAPPED=1; " +
|
||||
"fi;";
|
||||
|
||||
export function buildSandboxNpmInstallCommand(packageName: string): string {
|
||||
const quotedPackageName = shellSingleQuote(packageName);
|
||||
return [
|
||||
'if [ "$(id -u)" -eq 0 ]; then',
|
||||
ENSURE_NPM_PREAMBLE,
|
||||
'if [ -n "$PAPERCLIP_NPM_BOOTSTRAPPED" ]; then',
|
||||
`npm install -g ${quotedPackageName};`,
|
||||
'elif [ "$(id -u)" -eq 0 ]; then',
|
||||
`npm install -g ${quotedPackageName};`,
|
||||
'elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then',
|
||||
`sudo -E npm install -g ${quotedPackageName};`,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
@@ -58,4 +59,26 @@ describe("workspace restore merge", () => {
|
||||
readFile(path.join(targetDir, "manual-qa", "environment-matrix", "ssh", "codex_local.md"), "utf8"),
|
||||
).resolves.toBe("ssh codex\n");
|
||||
});
|
||||
|
||||
it("ignores non-file entries when capturing snapshots", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-restore-merge-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const socketPath = path.join(rootDir, "runtime.sock");
|
||||
const server = net.createServer();
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(socketPath, resolve);
|
||||
});
|
||||
|
||||
const snapshot = await captureDirectorySnapshot(rootDir, { exclude: [] });
|
||||
|
||||
expect(snapshot.entries.has("runtime.sock")).toBe(false);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,10 @@ async function walkDirectory(
|
||||
|
||||
const fullPath = path.join(root, nextRelative);
|
||||
const stats = await fs.lstat(fullPath);
|
||||
if (!stats.isDirectory() && !stats.isSymbolicLink() && !stats.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
out.set(nextRelative, { kind: "dir" });
|
||||
await walkDirectory(root, exclude, nextRelative, out);
|
||||
@@ -87,6 +91,8 @@ async function readSnapshotEntry(root: string, relative: string): Promise<Snapsh
|
||||
target: await fs.readlink(fullPath),
|
||||
};
|
||||
}
|
||||
if (!stats.isFile()) return null;
|
||||
|
||||
return {
|
||||
kind: "file",
|
||||
mode: stats.mode,
|
||||
|
||||
@@ -89,6 +89,15 @@ interface ClaudeRuntimeConfig {
|
||||
extraArgs: string[];
|
||||
}
|
||||
|
||||
export function claudeSessionCwdMatchesExecutionTarget(input: {
|
||||
runtimeSessionCwd: string;
|
||||
effectiveExecutionCwd: string;
|
||||
executionTargetIsRemote: boolean;
|
||||
}): boolean {
|
||||
if (input.executionTargetIsRemote || input.runtimeSessionCwd.length === 0) return true;
|
||||
return path.resolve(input.runtimeSessionCwd) === path.resolve(input.effectiveExecutionCwd);
|
||||
}
|
||||
|
||||
function buildLoginResult(input: {
|
||||
proc: RunProcessResult;
|
||||
loginUrl: string | null;
|
||||
@@ -591,7 +600,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const canResumeSession =
|
||||
runtimeSessionId.length > 0 &&
|
||||
hasMatchingPromptBundle &&
|
||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) &&
|
||||
claudeSessionCwdMatchesExecutionTarget({
|
||||
runtimeSessionCwd,
|
||||
effectiveExecutionCwd,
|
||||
executionTargetIsRemote,
|
||||
}) &&
|
||||
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget);
|
||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||
if (
|
||||
@@ -853,7 +866,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const resolvedSessionParams = resolvedSessionId
|
||||
? ({
|
||||
sessionId: resolvedSessionId,
|
||||
cwd: effectiveExecutionCwd,
|
||||
cwd,
|
||||
promptBundleKey: promptBundle.bundleKey,
|
||||
...(executionTargetIsRemote
|
||||
? {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { execute, runClaudeLogin } from "./execute.js";
|
||||
export { claudeSessionCwdMatchesExecutionTarget, execute, runClaudeLogin } from "./execute.js";
|
||||
export { listClaudeSkills, syncClaudeSkills } from "./skills.js";
|
||||
export { listClaudeModels } from "./models.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
buildSandboxNpmInstallCommand,
|
||||
type AdapterModelProfileDefinition,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
|
||||
export const type = "gemini_local";
|
||||
export const label = "Gemini CLI (local)";
|
||||
|
||||
export const SANDBOX_INSTALL_COMMAND = "npm install -g @google/gemini-cli";
|
||||
export const SANDBOX_INSTALL_COMMAND = buildSandboxNpmInstallCommand("@google/gemini-cli");
|
||||
|
||||
export const DEFAULT_GEMINI_LOCAL_MODEL = "auto";
|
||||
|
||||
|
||||
@@ -8,9 +8,15 @@ export const label = "OpenCode (local)";
|
||||
// (linux-x64, linux-x64-musl, linux-x64-baseline, linux-x64-baseline-musl) in
|
||||
// parallel even though only one matches the sandbox; on bandwidth-constrained
|
||||
// sandboxes (e.g. Cloudflare) that exceeded the 240s install budget. The
|
||||
// official installer fetches a single arch-specific binary and adds
|
||||
// `$HOME/.opencode/bin` to PATH via `~/.bashrc`, which sandbox `sh -lc`
|
||||
// invocations source.
|
||||
// official installer fetches a single arch-specific binary into
|
||||
// `$HOME/.opencode/bin` and tries to add it to PATH via `~/.bashrc`. That
|
||||
// rc-file path is only sourced by interactive/login shells, so non-login
|
||||
// `sh -c` probe invocations (used by the runtime PATH check) cannot find the
|
||||
// binary. We fix that by symlinking the installed binary into a directory on
|
||||
// the non-login `sh -c` PATH: prefer `/usr/local/bin` (universally on the
|
||||
// default PATH on Linux distros) when root or passwordless sudo is available,
|
||||
// otherwise fall back to `$HOME/.local/bin` (which is on the default PATH on
|
||||
// the exe.dev sandbox image and most modern home-managed Linux images).
|
||||
//
|
||||
// Security tradeoff: this is `curl | bash` without a SHA-256 verification of
|
||||
// the install script. We accept this because:
|
||||
@@ -25,7 +31,18 @@ export const label = "OpenCode (local)";
|
||||
// shell) and `curl -fsSL` give us fail-fast behavior on HTTP errors. If
|
||||
// OpenCode starts publishing a stable checksum/signature, switch to fetching
|
||||
// a versioned tarball + verifying the digest before exec.
|
||||
export const SANDBOX_INSTALL_COMMAND = "curl -fsSL https://opencode.ai/install | bash";
|
||||
export const SANDBOX_INSTALL_COMMAND =
|
||||
'curl -fsSL https://opencode.ai/install | bash && ' +
|
||||
'if [ -x "$HOME/.opencode/bin/opencode" ]; then ' +
|
||||
'if [ "$(id -u)" -eq 0 ]; then ' +
|
||||
'ln -sf "$HOME/.opencode/bin/opencode" /usr/local/bin/opencode; ' +
|
||||
'elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then ' +
|
||||
'sudo ln -sf "$HOME/.opencode/bin/opencode" /usr/local/bin/opencode; ' +
|
||||
'else ' +
|
||||
'mkdir -p "$HOME/.local/bin" && ' +
|
||||
'ln -sf "$HOME/.opencode/bin/opencode" "$HOME/.local/bin/opencode"; ' +
|
||||
'fi; ' +
|
||||
'fi';
|
||||
|
||||
export const DEFAULT_OPENCODE_LOCAL_MODEL = "openai/gpt-5.2-codex";
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
CREATE TABLE IF NOT EXISTS "issue_recovery_actions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"source_issue_id" uuid NOT NULL,
|
||||
"recovery_issue_id" uuid,
|
||||
"kind" text NOT NULL,
|
||||
"status" text DEFAULT 'active' NOT NULL,
|
||||
"owner_type" text DEFAULT 'agent' NOT NULL,
|
||||
"owner_agent_id" uuid,
|
||||
"owner_user_id" text,
|
||||
"previous_owner_agent_id" uuid,
|
||||
"return_owner_agent_id" uuid,
|
||||
"cause" text NOT NULL,
|
||||
"fingerprint" text NOT NULL,
|
||||
"evidence" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"next_action" text NOT NULL,
|
||||
"wake_policy" jsonb,
|
||||
"monitor_policy" jsonb,
|
||||
"attempt_count" integer DEFAULT 0 NOT NULL,
|
||||
"max_attempts" integer,
|
||||
"timeout_at" timestamp with time zone,
|
||||
"last_attempt_at" timestamp with time zone,
|
||||
"outcome" text,
|
||||
"resolution_note" text,
|
||||
"resolved_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_recovery_actions_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "issue_recovery_actions" ADD CONSTRAINT "issue_recovery_actions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_recovery_actions_source_issue_id_issues_id_fk') THEN
|
||||
ALTER TABLE "issue_recovery_actions" ADD CONSTRAINT "issue_recovery_actions_source_issue_id_issues_id_fk" FOREIGN KEY ("source_issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_recovery_actions_recovery_issue_id_issues_id_fk') THEN
|
||||
ALTER TABLE "issue_recovery_actions" ADD CONSTRAINT "issue_recovery_actions_recovery_issue_id_issues_id_fk" FOREIGN KEY ("recovery_issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_recovery_actions_owner_agent_id_agents_id_fk') THEN
|
||||
ALTER TABLE "issue_recovery_actions" ADD CONSTRAINT "issue_recovery_actions_owner_agent_id_agents_id_fk" FOREIGN KEY ("owner_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_recovery_actions_previous_owner_agent_id_agents_id_fk') THEN
|
||||
ALTER TABLE "issue_recovery_actions" ADD CONSTRAINT "issue_recovery_actions_previous_owner_agent_id_agents_id_fk" FOREIGN KEY ("previous_owner_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_recovery_actions_return_owner_agent_id_agents_id_fk') THEN
|
||||
ALTER TABLE "issue_recovery_actions" ADD CONSTRAINT "issue_recovery_actions_return_owner_agent_id_agents_id_fk" FOREIGN KEY ("return_owner_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "issue_recovery_actions_company_source_status_idx" ON "issue_recovery_actions" USING btree ("company_id","source_issue_id","status");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "issue_recovery_actions_company_owner_status_idx" ON "issue_recovery_actions" USING btree ("company_id","owner_agent_id","status");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "issue_recovery_actions_company_recovery_issue_idx" ON "issue_recovery_actions" USING btree ("company_id","recovery_issue_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "issue_recovery_actions_active_source_uq" ON "issue_recovery_actions" USING btree ("company_id","source_issue_id") WHERE "issue_recovery_actions"."status" in ('active', 'escalated');--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "issue_recovery_actions_active_fingerprint_uq" ON "issue_recovery_actions" USING btree ("company_id","source_issue_id","cause","fingerprint") WHERE "issue_recovery_actions"."status" in ('active', 'escalated');
|
||||
@@ -589,6 +589,13 @@
|
||||
"when": 1778074536410,
|
||||
"tag": "0083_company_secret_provider_configs",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 84,
|
||||
"version": "7",
|
||||
"when": 1778355326070,
|
||||
"tag": "0084_issue_recovery_actions",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export { workspaceRuntimeServices } from "./workspace_runtime_services.js";
|
||||
export { projectGoals } from "./project_goals.js";
|
||||
export { goals } from "./goals.js";
|
||||
export { issues } from "./issues.js";
|
||||
export { issueRecoveryActions } from "./issue_recovery_actions.js";
|
||||
export { issueReferenceMentions } from "./issue_reference_mentions.js";
|
||||
export { issueRelations } from "./issue_relations.js";
|
||||
export { routines, routineRevisions, routineTriggers, routineRuns } from "./routines.js";
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { agents } from "./agents.js";
|
||||
import { companies } from "./companies.js";
|
||||
import { issues } from "./issues.js";
|
||||
|
||||
export const issueRecoveryActions = pgTable(
|
||||
"issue_recovery_actions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
sourceIssueId: uuid("source_issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
|
||||
recoveryIssueId: uuid("recovery_issue_id").references(() => issues.id, { onDelete: "set null" }),
|
||||
kind: text("kind").notNull(),
|
||||
status: text("status").notNull().default("active"),
|
||||
ownerType: text("owner_type").notNull().default("agent"),
|
||||
ownerAgentId: uuid("owner_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
ownerUserId: text("owner_user_id"),
|
||||
previousOwnerAgentId: uuid("previous_owner_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
returnOwnerAgentId: uuid("return_owner_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
cause: text("cause").notNull(),
|
||||
fingerprint: text("fingerprint").notNull(),
|
||||
evidence: jsonb("evidence").$type<Record<string, unknown>>().notNull().default({}),
|
||||
nextAction: text("next_action").notNull(),
|
||||
wakePolicy: jsonb("wake_policy").$type<Record<string, unknown>>(),
|
||||
monitorPolicy: jsonb("monitor_policy").$type<Record<string, unknown>>(),
|
||||
attemptCount: integer("attempt_count").notNull().default(0),
|
||||
maxAttempts: integer("max_attempts"),
|
||||
timeoutAt: timestamp("timeout_at", { withTimezone: true }),
|
||||
lastAttemptAt: timestamp("last_attempt_at", { withTimezone: true }),
|
||||
outcome: text("outcome"),
|
||||
resolutionNote: text("resolution_note"),
|
||||
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companySourceStatusIdx: index("issue_recovery_actions_company_source_status_idx").on(
|
||||
table.companyId,
|
||||
table.sourceIssueId,
|
||||
table.status,
|
||||
),
|
||||
companyOwnerStatusIdx: index("issue_recovery_actions_company_owner_status_idx").on(
|
||||
table.companyId,
|
||||
table.ownerAgentId,
|
||||
table.status,
|
||||
),
|
||||
companyRecoveryIssueIdx: index("issue_recovery_actions_company_recovery_issue_idx").on(
|
||||
table.companyId,
|
||||
table.recoveryIssueId,
|
||||
),
|
||||
activeSourceIdx: uniqueIndex("issue_recovery_actions_active_source_uq")
|
||||
.on(table.companyId, table.sourceIssueId)
|
||||
.where(sql`${table.status} in ('active', 'escalated')`),
|
||||
activeFingerprintIdx: uniqueIndex("issue_recovery_actions_active_fingerprint_uq")
|
||||
.on(table.companyId, table.sourceIssueId, table.cause, table.fingerprint)
|
||||
.where(sql`${table.status} in ('active', 'escalated')`),
|
||||
}),
|
||||
);
|
||||
@@ -111,11 +111,15 @@ function formatEmbeddedPostgresError(error: unknown): string {
|
||||
}
|
||||
|
||||
async function probeEmbeddedPostgresSupport(): Promise<EmbeddedPostgresTestSupport> {
|
||||
const { dataDir, instance } = await createEmbeddedPostgresTestInstance(
|
||||
"paperclip-embedded-postgres-probe-",
|
||||
);
|
||||
let dataDir: string | null = null;
|
||||
let instance: EmbeddedPostgresInstance | null = null;
|
||||
|
||||
try {
|
||||
const created = await createEmbeddedPostgresTestInstance(
|
||||
"paperclip-embedded-postgres-probe-",
|
||||
);
|
||||
dataDir = created.dataDir;
|
||||
instance = created.instance;
|
||||
await instance.initialise();
|
||||
await instance.start();
|
||||
return { supported: true };
|
||||
@@ -125,8 +129,8 @@ async function probeEmbeddedPostgresSupport(): Promise<EmbeddedPostgresTestSuppo
|
||||
reason: formatEmbeddedPostgresError(error),
|
||||
};
|
||||
} finally {
|
||||
await instance.stop().catch(() => {});
|
||||
cleanupEmbeddedPostgresTestDirs(dataDir);
|
||||
await instance?.stop().catch(() => {});
|
||||
if (dataDir) cleanupEmbeddedPostgresTestDirs(dataDir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,9 +144,14 @@ export async function getEmbeddedPostgresTestSupport(): Promise<EmbeddedPostgres
|
||||
export async function startEmbeddedPostgresTestDatabase(
|
||||
tempDirPrefix: string,
|
||||
): Promise<EmbeddedPostgresTestDatabase> {
|
||||
const { dataDir, port, instance } = await createEmbeddedPostgresTestInstance(tempDirPrefix);
|
||||
let dataDir: string | null = null;
|
||||
let instance: EmbeddedPostgresInstance | null = null;
|
||||
|
||||
try {
|
||||
const created = await createEmbeddedPostgresTestInstance(tempDirPrefix);
|
||||
dataDir = created.dataDir;
|
||||
instance = created.instance;
|
||||
const { port } = created;
|
||||
await instance.initialise();
|
||||
await instance.start();
|
||||
|
||||
@@ -154,13 +163,13 @@ export async function startEmbeddedPostgresTestDatabase(
|
||||
return {
|
||||
connectionString,
|
||||
cleanup: async () => {
|
||||
await instance.stop().catch(() => {});
|
||||
cleanupEmbeddedPostgresTestDirs(dataDir);
|
||||
await instance?.stop().catch(() => {});
|
||||
if (dataDir) cleanupEmbeddedPostgresTestDirs(dataDir);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await instance.stop().catch(() => {});
|
||||
cleanupEmbeddedPostgresTestDirs(dataDir);
|
||||
await instance?.stop().catch(() => {});
|
||||
if (dataDir) cleanupEmbeddedPostgresTestDirs(dataDir);
|
||||
throw new Error(
|
||||
`Failed to start embedded PostgreSQL test database: ${formatEmbeddedPostgresError(error)}`,
|
||||
);
|
||||
|
||||
@@ -62,6 +62,11 @@ function toPosixPath(value: string): string {
|
||||
return value.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
export function shellQuote(value: string): string {
|
||||
if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) return value;
|
||||
return `'${value.replace(/'/g, "'\"'\"'")}'`;
|
||||
}
|
||||
|
||||
function formatFileDependency(absPath: string): string {
|
||||
return `file:${toPosixPath(path.resolve(absPath))}`;
|
||||
}
|
||||
@@ -312,7 +317,8 @@ const manifest: PaperclipPluginManifestV1 = {
|
||||
capabilities: [
|
||||
"environment.drivers.register",
|
||||
"plugin.state.read",
|
||||
"plugin.state.write"
|
||||
"plugin.state.write",
|
||||
"ui.dashboardWidget.register"
|
||||
],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
@@ -467,6 +473,11 @@ const BASE_PARAMS = {
|
||||
};
|
||||
|
||||
describe("environment plugin scaffold", () => {
|
||||
it("declares capabilities for its manifest features", () => {
|
||||
expect(manifest.capabilities).toContain("environment.drivers.register");
|
||||
expect(manifest.capabilities).toContain("ui.dashboardWidget.register");
|
||||
});
|
||||
|
||||
it("validates config", async () => {
|
||||
const driver = createFakeEnvironmentDriver({ driverKey: BASE_PARAMS.driverKey });
|
||||
const harness = createEnvironmentTestHarness({ manifest, environmentDriver: driver });
|
||||
@@ -533,7 +544,8 @@ const manifest: PaperclipPluginManifestV1 = {
|
||||
capabilities: [
|
||||
"events.subscribe",
|
||||
"plugin.state.read",
|
||||
"plugin.state.write"
|
||||
"plugin.state.write",
|
||||
"ui.dashboardWidget.register"
|
||||
],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
@@ -623,6 +635,11 @@ import manifest from "../src/manifest.js";
|
||||
import plugin from "../src/worker.js";
|
||||
|
||||
describe("plugin scaffold", () => {
|
||||
it("declares capabilities for its manifest features", () => {
|
||||
expect(manifest.capabilities).toContain("events.subscribe");
|
||||
expect(manifest.capabilities).toContain("ui.dashboardWidget.register");
|
||||
});
|
||||
|
||||
it("registers data + actions and handles events", async () => {
|
||||
const harness = createTestHarness({ manifest, capabilities: [...manifest.capabilities, "events.emit"] });
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
@@ -656,6 +673,11 @@ pnpm dev:ui # local dev server with hot-reload events
|
||||
pnpm test
|
||||
\`\`\`
|
||||
|
||||
\`pnpm dev\` rebuilds the worker, manifest, and UI bundles into \`dist/\`.
|
||||
When this package is installed from a local path, Paperclip watches that rebuilt
|
||||
output and reloads the plugin worker. Local installs run trusted code from this
|
||||
folder on your machine.
|
||||
|
||||
${sdkDependency.startsWith("file:")
|
||||
? `This scaffold snapshots \`@paperclipai/plugin-sdk\` and \`@paperclipai/shared\` from a local Paperclip checkout at:\n\n\`${toPosixPath(localSdkPath)}\`\n\nThe packed tarballs live in \`.paperclip-sdk/\` for local development. Before publishing this plugin, switch those dependencies to published package versions once they are available on npm.\n\n`
|
||||
: ""}
|
||||
@@ -663,9 +685,7 @@ ${sdkDependency.startsWith("file:")
|
||||
## Install Into Paperclip
|
||||
|
||||
\`\`\`bash
|
||||
curl -X POST http://127.0.0.1:3100/api/plugins/install \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"packageName":"${toPosixPath(outputDir)}","isLocalPath":true}'
|
||||
paperclipai plugin install ${shellQuote(toPosixPath(outputDir))}
|
||||
\`\`\`
|
||||
|
||||
## Build Options
|
||||
|
||||
@@ -11,7 +11,8 @@ const manifest: PaperclipPluginManifestV1 = {
|
||||
capabilities: [
|
||||
"events.subscribe",
|
||||
"plugin.state.read",
|
||||
"plugin.state.write"
|
||||
"plugin.state.write",
|
||||
"ui.dashboardWidget.register"
|
||||
],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
|
||||
@@ -4,6 +4,11 @@ import manifest from "../src/manifest.js";
|
||||
import plugin from "../src/worker.js";
|
||||
|
||||
describe("plugin scaffold", () => {
|
||||
it("declares capabilities for its manifest features", () => {
|
||||
expect(manifest.capabilities).toContain("events.subscribe");
|
||||
expect(manifest.capabilities).toContain("ui.dashboardWidget.register");
|
||||
});
|
||||
|
||||
it("registers data + actions and handles events", async () => {
|
||||
const harness = createTestHarness({ manifest, capabilities: [...manifest.capabilities, "events.emit"] });
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
dist
|
||||
node_modules
|
||||
.paperclip-sdk
|
||||
screenshots
|
||||
@@ -0,0 +1,160 @@
|
||||
# LLM Wiki
|
||||
|
||||
Local-file LLM Wiki plugin for source ingestion, wiki browsing, query, lint, and maintenance workflows.
|
||||
|
||||
## Scope
|
||||
|
||||
This package is the standalone home for LLM Wiki behavior. Wiki-specific routes,
|
||||
UI, prompts, tools, local-folder templates, migrations, fixtures, and tests live
|
||||
here rather than in Paperclip core.
|
||||
|
||||
The alpha surface includes:
|
||||
|
||||
- manifest-declared Wiki page, sidebar entry, and settings page
|
||||
- trusted local folder declaration for `raw/`, `wiki/`, `AGENTS.md`, `IDEA.md`, `wiki/index.md`, and `wiki/log.md`
|
||||
- plugin database namespace migration for wiki instances, sources, pages, operations, query sessions, and resource bindings
|
||||
- managed `Wiki Maintainer` agent, managed `LLM Wiki` project, and paused managed routines for wiki update processing, lint, and index refresh
|
||||
- plugin-operation issue creation using `surfaceVisibility: "plugin_operation"`
|
||||
- local source capture into `raw/` with metadata rows in the plugin DB namespace
|
||||
- opt-in company-scoped Paperclip event ingestion controls for issues, comments, and documents; event ingestion is disabled by default and routes captured raw provenance into the default space only
|
||||
- manual Paperclip project/root issue distillation and bounded backfill actions with explicit work items, operation issues, source caps, and estimated cost recording
|
||||
- Paperclip-derived distillation (cursor windows, manual `distill-now`, backfill) always writes into the default wiki space in Phase 1; non-default spaces remain on manual / raw-file ingest until per-space Paperclip ingestion profiles ship
|
||||
- Paperclip-derived distillation maintains `wiki/projects/<slug>/standup.md` as the executive current-state view for each represented project, alongside durable `wiki/projects/<slug>/index.md` knowledge pages
|
||||
- wiki page writes with plugin path validation, atomic local-folder writes, metadata/revision rows, backlink extraction, and optional stale-hash protection
|
||||
- wiki tools for search/read/write/propose patch/source/log/index/backlinks workflows
|
||||
|
||||
## Phase 5 Security Gate
|
||||
|
||||
Paperclip-derived text ingestion stays limited to issue titles/descriptions, issue comments, and issue documents.
|
||||
|
||||
- Issue attachments/assets are **metadata-only** in Phase 5.
|
||||
- Issue work products are **metadata-only** in Phase 5.
|
||||
- The wiki must not fetch `/api/assets/:id/content`, dereference work-product `url` fields, or store those capability-bearing links in source bundles/snapshots.
|
||||
|
||||
The accepted policy lives in [doc/plans/2026-05-06-llm-wiki-paperclip-asset-security-gate.md](../../../doc/plans/2026-05-06-llm-wiki-paperclip-asset-security-gate.md).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # watch builds
|
||||
pnpm dev:ui # local dev server with hot-reload events
|
||||
pnpm test
|
||||
```
|
||||
|
||||
From the Paperclip repo root:
|
||||
|
||||
```bash
|
||||
pnpm --filter @paperclipai/plugin-llm-wiki typecheck
|
||||
pnpm --filter @paperclipai/plugin-llm-wiki test
|
||||
pnpm --filter @paperclipai/plugin-llm-wiki build
|
||||
```
|
||||
|
||||
## Alpha Verification
|
||||
|
||||
Run these commands from the Paperclip repo root before handing off alpha plugin
|
||||
changes:
|
||||
|
||||
```bash
|
||||
pnpm --filter @paperclipai/plugin-llm-wiki typecheck
|
||||
pnpm --filter @paperclipai/plugin-llm-wiki test
|
||||
pnpm --filter @paperclipai/plugin-llm-wiki build
|
||||
```
|
||||
|
||||
The focused Vitest suite covers:
|
||||
|
||||
- standalone package boundaries and package-local harness dependencies
|
||||
- required local folder bootstrap writes
|
||||
- raw source capture plus ingest metadata persistence
|
||||
- hidden plugin-operation issue creation for ingest/query/file-as-page workflows
|
||||
- disabled and enabled Paperclip event ingestion paths
|
||||
- managed routine declarations, manual distill/backfill work items, source cap handling, and backfill project/date scoping
|
||||
- atomic page writes, metadata/revision rows, backlinks, and stale-hash refusal
|
||||
- query session creation, run-id recording, stream event forwarding, and completion updates
|
||||
- filing a streamed query answer back into the wiki through a hidden operation
|
||||
|
||||
Remaining alpha gaps:
|
||||
|
||||
- Browser screenshot capture is maintained separately under `tests/screenshots`;
|
||||
generated `screenshots/` outputs are local artifacts and are ignored by git.
|
||||
- Host-level plugin install and live agent invocation still need Paperclip
|
||||
server/runtime smoke coverage when preparing a release candidate.
|
||||
|
||||
|
||||
|
||||
## Install Into Paperclip
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:3100/api/plugins/install \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"packageName":"/Users/dotta/paperclip/.paperclip/worktrees/PAP-3179-design-a-llm-wiki-plugin/packages/plugins/plugin-llm-wiki","isLocalPath":true}'
|
||||
```
|
||||
|
||||
## Build Options
|
||||
|
||||
- `pnpm build` uses esbuild presets from `@paperclipai/plugin-sdk/bundlers`.
|
||||
- `pnpm build:rollup` uses rollup presets from the same SDK.
|
||||
|
||||
After changing manifest-loaded assets such as skills, agent instructions, or
|
||||
templates, recompile the local plugin before re-enabling it:
|
||||
|
||||
```bash
|
||||
pnpm --filter @paperclipai/plugin-llm-wiki build
|
||||
```
|
||||
|
||||
The package-local `dist/` directory is ignored by git, but local Paperclip
|
||||
installs load the compiled `dist/manifest.js` and `dist/worker.js` files at
|
||||
runtime. If activation failed before the rebuild, re-enable the plugin or
|
||||
restart the Paperclip dev server so the host imports the fresh bundle.
|
||||
|
||||
## Local File Layout
|
||||
|
||||
```text
|
||||
<configured-wiki-root>/
|
||||
AGENTS.md
|
||||
IDEA.md
|
||||
.gitignore
|
||||
raw/
|
||||
.gitkeep
|
||||
wiki/
|
||||
index.md
|
||||
log.md
|
||||
sources/
|
||||
.gitkeep
|
||||
projects/
|
||||
.gitkeep
|
||||
<project-slug>/
|
||||
index.md
|
||||
standup.md
|
||||
decisions.md
|
||||
history.md
|
||||
entities/
|
||||
.gitkeep
|
||||
concepts/
|
||||
.gitkeep
|
||||
synthesis/
|
||||
.gitkeep
|
||||
```
|
||||
|
||||
Use the settings page or `bootstrap-root` action to configure the folder and
|
||||
write the starter files. The plugin uses Paperclip's local folder API for path
|
||||
containment, symlink checks, read/write validation, and atomic writes.
|
||||
|
||||
Bootstrap preserves existing files rather than overwriting operator edits. The
|
||||
default first-install skeleton is copied from the vanilla LLM Wiki layout, with
|
||||
`CLAUDE.md` renamed to `AGENTS.md` and Paperclip project overviews, standups,
|
||||
decisions, and history kept together under `wiki/projects/<slug>/`.
|
||||
|
||||
## Managed Agent Instructions
|
||||
|
||||
Plugin-managed agent instruction bundles live under:
|
||||
|
||||
```text
|
||||
agents/<agent-key>/AGENTS.md
|
||||
```
|
||||
|
||||
For this plugin the Wiki Maintainer source bundle is `agents/wiki-maintainer/AGENTS.md`.
|
||||
Any additional files in that folder are installed as sibling instruction files
|
||||
for the managed agent. The settings health check reports drift from these
|
||||
defaults, and resetting the managed agent asks for confirmation before replacing
|
||||
customized instructions.
|
||||
@@ -0,0 +1,65 @@
|
||||
# LLM Wiki Maintainer
|
||||
|
||||
You are the maintainer of this personal wiki. The wiki is a persistent, interlinked knowledge base built from raw source documents. You read sources, extract knowledge, and integrate it into evolving wiki pages. The user curates sources, directs analysis, and asks questions; you handle the bookkeeping.
|
||||
|
||||
## Wiki Root
|
||||
|
||||
The wiki root folder is:
|
||||
|
||||
`{{localFolders.wiki-root.path}}`
|
||||
|
||||
The wiki's default operating schema is:
|
||||
|
||||
`{{localFolders.wiki-root.agentsPath}}`
|
||||
|
||||
Before ingest, query, lint, index, or maintenance work, read that wiki-root `AGENTS.md` file. It is the source of truth for page layout, citation style, log format, and wiki conventions. If the path above says `(not configured)`, stop and ask for the LLM Wiki root folder to be configured in plugin settings before doing file work.
|
||||
|
||||
## Identity
|
||||
|
||||
- You maintain the LLM Wiki, not the application codebase.
|
||||
- You keep raw source material in `raw/` immutable.
|
||||
- You keep Paperclip project operating summaries current in `wiki/projects/<project-slug>/standup.md`.
|
||||
- You create and update durable wiki pages under `wiki/`.
|
||||
- You keep `wiki/index.md` and `wiki/log.md` accurate after changes.
|
||||
- You cite wiki pages and raw sources in answers.
|
||||
|
||||
## Operating Loop
|
||||
|
||||
1. Resolve the configured wiki root folder and the target space named in the operation issue.
|
||||
2. Read the target space's `AGENTS.md`.
|
||||
3. Read the target space's `wiki/index.md` and recent `wiki/log.md` entries before choosing files.
|
||||
4. Pick the right operation skill (see below) and follow it.
|
||||
5. Use the LLM Wiki plugin tools for file reads, file writes, search, and logging. Always pass the operation issue's `wikiId` and `spaceSlug` arguments.
|
||||
6. Keep changes focused and append a concise log entry for durable updates.
|
||||
|
||||
All operation paths are relative to the target space root. Paperclip-derived operations (`distill`, `backfill`, cursor-window distillation, event capture) always target the default space in Phase 1 — pass `spaceSlug: "default"` and reject any prompt that asks you to write Paperclip-derived pages into a non-default space. Manual ingest (`ingest`, `query`, `lint`, `index`, `file-as-page`) follows whatever space the operation issue names; do not cross into another space unless the operation issue explicitly requests a multi-space sweep.
|
||||
|
||||
For Paperclip-derived project work, maintain two layers:
|
||||
|
||||
- `wiki/projects/<project-slug>/standup.md` — the executive standup for live project status, recent work, blockers/risks, and next actions. Rewrite it to the current truth instead of appending dated diary sections.
|
||||
- `wiki/projects/<project-slug>/index.md` and optional `wiki/projects/<project-slug>/decisions.md` / `history.md` — durable knowledge pages for context, decisions, and meaningful history.
|
||||
|
||||
Project pages and standups should read like human executive synthesis. Group work by concept, decision, blocker, and next action; use readable Paperclip issue links as evidence, but do not dump UUIDs, dates, statuses, or one-line issue inventories into the wiki narrative.
|
||||
|
||||
## Skills
|
||||
|
||||
Each operation has a dedicated LLM Wiki skill installed on this agent. Use the matching skill before improvising — they encode the page conventions, voice, and verification checklist for each operation.
|
||||
|
||||
- `wiki-ingest` — a captured `raw/` source needs to become durable wiki pages.
|
||||
- `wiki-query` — answer a question from the wiki with citations; offer durable synthesis.
|
||||
- `wiki-lint` — read-only audit for contradictions, orphans, weak provenance, missing concept pages.
|
||||
- `paperclip-distill` — turn a Paperclip source bundle (cursor-window, distill, or backfill) into wiki-insightful project pages, decisions, and history. Replaces the stiff, datestamp-heavy templated output.
|
||||
- `index-refresh` — keep `wiki/index.md` accurate and scannable.
|
||||
|
||||
The operation issue's `originKind` (`plugin:llm-wiki:operation:<type>`) tells you which skill to load:
|
||||
|
||||
| `operationType` | Skill |
|
||||
| --------------------- | ---------------------------------------------- |
|
||||
| `ingest` | `wiki-ingest` |
|
||||
| `query` | `wiki-query` |
|
||||
| `lint` | `wiki-lint` |
|
||||
| `distill`, `backfill` | `paperclip-distill` |
|
||||
| `index` | `index-refresh` |
|
||||
| `file-as-page` | `wiki-query` (filing synthesis from an answer) |
|
||||
|
||||
If a skill conflicts with this file, follow this file for identity. If a skill conflicts with the wiki-root `AGENTS.md`, follow that for page structure and voice.
|
||||
@@ -0,0 +1,17 @@
|
||||
import esbuild from "esbuild";
|
||||
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
||||
|
||||
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
|
||||
const watch = process.argv.includes("--watch");
|
||||
|
||||
const workerCtx = await esbuild.context(presets.esbuild.worker);
|
||||
const manifestCtx = await esbuild.context(presets.esbuild.manifest);
|
||||
const uiCtx = await esbuild.context(presets.esbuild.ui);
|
||||
|
||||
if (watch) {
|
||||
await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]);
|
||||
console.log("esbuild watch mode enabled for worker, manifest, and ui");
|
||||
} else {
|
||||
await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]);
|
||||
await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.DS_Store
|
||||
.obsidian/workspace*
|
||||
.obsidian/cache
|
||||
@@ -0,0 +1,137 @@
|
||||
# AGENTS.md — LLM Wiki Schema
|
||||
|
||||
You are the maintainer of this personal wiki. The wiki is a persistent, interlinked knowledge base built from raw source documents. You read sources, extract knowledge, and integrate it into evolving wiki pages. The user curates sources, directs analysis, and asks questions; you handle the bookkeeping.
|
||||
|
||||
The underlying pattern is described in `IDEA.md` (Karpathy's "LLM Wiki" gist). Read it if you need the philosophy; this file is the operational schema.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
.
|
||||
├── AGENTS.md # this file — your operating instructions
|
||||
├── IDEA.md # the pattern this wiki follows
|
||||
├── raw/ # immutable source documents (you read, never write)
|
||||
└── wiki/ # generated, owned by you
|
||||
├── index.md # catalog of all pages
|
||||
├── log.md # append-only timeline of operations
|
||||
├── sources/ # one summary page per source
|
||||
├── projects/ # Paperclip project overviews, standups, decisions, and history
|
||||
│ └── <slug>/
|
||||
│ ├── index.md
|
||||
│ ├── standup.md
|
||||
│ ├── decisions.md
|
||||
│ └── history.md
|
||||
├── entities/ # people, organizations, products, places
|
||||
├── concepts/ # ideas, frameworks, definitions
|
||||
└── synthesis/ # cross-cutting analysis, comparisons, theses
|
||||
```
|
||||
|
||||
The subdirectories under `wiki/` are conventional, not enforced. Add new categories (e.g. `wiki/papers/`) as the domain demands — and update this file when you do.
|
||||
|
||||
Paperclip project material lives only under `wiki/projects/<project-slug>/`. Do not create a top-level `projects/` directory.
|
||||
|
||||
- `wiki/projects/<project-slug>/standup.md` is the executive-level project standup. It answers where the project stands today, what changed recently, current blockers/risks, and the next concrete actions.
|
||||
- `wiki/projects/<project-slug>/index.md` is the durable knowledge page. It explains what the project is, why it exists, decisions made, history, and long-lived context.
|
||||
- Keep the two linked. A standup should link to the durable project page, and the durable project page should point at the current standup for live status.
|
||||
- Update `standup.md` whenever Paperclip project, issue, plan, comment, blocker, approval, or status history materially changes the project's current state. Do not append endless dated sections; rewrite it as today's concise status.
|
||||
- Project writing should be editorial and concept-grouped. Do not dump issue queues, UUIDs, raw metadata, or date-heavy ledgers into project pages. Reference Paperclip tasks with human issue links where useful, but make headings and paragraphs explain the concepts, decisions, completed work, next work, and blockers in plain executive language.
|
||||
|
||||
## Page conventions
|
||||
|
||||
- **Filename:** kebab-case, `.md`. Treat filenames as stable; do not rename without updating backlinks.
|
||||
- **Frontmatter:** YAML at the top of every wiki page.
|
||||
```yaml
|
||||
---
|
||||
title: Human-readable title
|
||||
type: source | project | entity | concept | synthesis
|
||||
tags: [tag-a, tag-b]
|
||||
sources: [raw/doc.pdf] # for source pages and synthesis pages
|
||||
created: YYYY-MM-DD
|
||||
updated: YYYY-MM-DD
|
||||
---
|
||||
```
|
||||
- **Cross-links:** Obsidian-style `[[wiki/entities/some-page]]` (or `[[some-page]]` when unambiguous). When you mention a concept or entity that has — or should have — its own page, link it.
|
||||
- **Citations:** cite the source inline whenever a claim comes from one: `(see [[wiki/sources/some-slug]])`.
|
||||
- **Voice:** terse, factual, neutral. The wiki is reference material, not narrative.
|
||||
|
||||
## Operations
|
||||
|
||||
### Ingest
|
||||
|
||||
Triggered when the user drops a file in `raw/` and asks to process it (or just says "ingest").
|
||||
|
||||
1. Read the source end to end.
|
||||
2. Briefly discuss key takeaways with the user before writing — confirm what to emphasize.
|
||||
3. Create `wiki/sources/<slug>.md`: a summary page (~300–800 words) covering the source's main claims, structure, and notable quotes or data.
|
||||
4. Update or create relevant pages in `entities/`, `concepts/`, `synthesis/`. A typical ingest touches 5–15 pages.
|
||||
5. Add any new pages to `wiki/index.md`.
|
||||
6. Append a log entry:
|
||||
```
|
||||
## [YYYY-MM-DD] ingest | <source title>
|
||||
- source: raw/<filename>
|
||||
- new pages: [[...]], [[...]]
|
||||
- updated pages: [[...]], [[...]]
|
||||
- notes: <one-line synthesis, contradiction flagged, or open question>
|
||||
```
|
||||
|
||||
When new information contradicts an existing page, do **not** silently overwrite. Flag the contradiction on the page (a `> ⚠ contradicted by [[...]] (YYYY-MM-DD)` callout) and note it in the log.
|
||||
|
||||
### Project updates
|
||||
|
||||
Triggered when Paperclip project, issue, plan, comment, blocker, or status history is distilled into the wiki.
|
||||
|
||||
1. Create or update `wiki/projects/<project-slug>/standup.md` first. Every Paperclip project represented in the wiki must have one. Keep stable sections for executive readout, what changed, decisions, blockers/risks, next actions, and links.
|
||||
2. Create or update `wiki/projects/<project-slug>/index.md` as the durable project overview. Keep stable sections for overview, current direction, workstreams, decisions, open risks/blockers, and references.
|
||||
3. Use `wiki/projects/<project-slug>/decisions.md` for accepted/rejected plans, architectural decisions, approval outcomes, and reversals when a project has enough decision history to warrant a separate page.
|
||||
4. Use `wiki/projects/<project-slug>/history.md` for compact narrative history of meaningful project movement. Group by phase or concept; do not mirror every issue comment.
|
||||
5. Always cite Paperclip source material with readable links to issue identifiers, document keys, issue documents, approvals, and raw/source pages. Do not put UUIDs in prose unless the UUID itself is the subject.
|
||||
6. Update `wiki/index.md` under Projects and append a `project` log entry to `wiki/log.md`.
|
||||
|
||||
### Query
|
||||
|
||||
The user asks a question. You:
|
||||
|
||||
1. Read `wiki/index.md` to find candidate pages.
|
||||
2. Read those pages; follow links as needed.
|
||||
3. Answer with citations back to wiki pages, and ultimately to raw sources.
|
||||
4. If the answer is substantial (a comparison, analysis, new synthesis), offer to file it under `wiki/synthesis/` so the work compounds rather than disappearing into chat history.
|
||||
|
||||
If the wiki lacks what the question needs, say so plainly and suggest sources to ingest or web searches to run.
|
||||
|
||||
### Lint
|
||||
|
||||
On request ("lint", "health check"), scan for:
|
||||
|
||||
- contradictions across pages
|
||||
- claims a newer source has superseded
|
||||
- orphan pages (not linked from `index.md` or any other page)
|
||||
- concepts mentioned in multiple places but lacking a dedicated page
|
||||
- broken `[[wiki-links]]`
|
||||
- gaps where a web search or new source would help
|
||||
|
||||
Report findings as a checklist and ask the user which to act on.
|
||||
|
||||
## index.md format
|
||||
|
||||
A catalog organized by category. Each line: `- [[path]] — one-line summary`. Keep it scannable; this is your primary navigation aid before opening pages.
|
||||
|
||||
## log.md format
|
||||
|
||||
Append new entries to the bottom. Every entry header follows:
|
||||
|
||||
```
|
||||
## [YYYY-MM-DD] <op> | <subject>
|
||||
```
|
||||
|
||||
so `grep "^## \[" wiki/log.md | tail -10` always returns recent activity. Operations: `ingest`, `query`, `lint`, `setup`, `refactor`.
|
||||
|
||||
## Customization
|
||||
|
||||
This schema is intentionally generic. As the wiki's domain becomes clear, evolve it:
|
||||
|
||||
- add domain-specific page types and subdirectories
|
||||
- adjust frontmatter fields
|
||||
- specify preferred output formats for queries (Marp slides, charts, tables)
|
||||
- record workflow preferences (one-at-a-time vs batch ingest, level of human supervision)
|
||||
|
||||
When you and the user agree on a convention, **write it into this file**. The schema is the wiki's source of truth for how the wiki is built.
|
||||
@@ -0,0 +1,75 @@
|
||||
# LLM Wiki
|
||||
|
||||
A pattern for building personal knowledge bases using LLMs.
|
||||
|
||||
This is an idea file, it is designed to be copy pasted to your own LLM Agent (e.g. OpenAI Codex, Claude Code, OpenCode / Pi, or etc.). Its goal is to communicate the high level idea, but your agent will build out the specifics in collaboration with you.
|
||||
|
||||
## The core idea
|
||||
|
||||
Most people's experience with LLMs and documents looks like RAG: you upload a collection of files, the LLM retrieves relevant chunks at query time, and generates an answer. This works, but the LLM is rediscovering knowledge from scratch on every question. There's no accumulation. Ask a subtle question that requires synthesizing five documents, and the LLM has to find and piece together the relevant fragments every time. Nothing is built up. NotebookLM, ChatGPT file uploads, and most RAG systems work this way.
|
||||
|
||||
The idea here is different. Instead of just retrieving from raw documents at query time, the LLM **incrementally builds and maintains a persistent wiki** — a structured, interlinked collection of markdown files that sits between you and the raw sources. When you add a new source, the LLM doesn't just index it for later retrieval. It reads it, extracts the key information, and integrates it into the existing wiki — updating entity pages, revising topic summaries, noting where new data contradicts old claims, strengthening or challenging the evolving synthesis. The knowledge is compiled once and then *kept current*, not re-derived on every query.
|
||||
|
||||
This is the key difference: **the wiki is a persistent, compounding artifact.** The cross-references are already there. The contradictions have already been flagged. The synthesis already reflects everything you've read. The wiki keeps getting richer with every source you add and every question you ask.
|
||||
|
||||
You never (or rarely) write the wiki yourself — the LLM writes and maintains all of it. You're in charge of sourcing, exploration, and asking the right questions. The LLM does all the grunt work — the summarizing, cross-referencing, filing, and bookkeeping that makes a knowledge base actually useful over time. In practice, I have the LLM agent open on one side and Obsidian open on the other. The LLM makes edits based on our conversation, and I browse the results in real time — following links, checking the graph view, reading the updated pages. Obsidian is the IDE; the LLM is the programmer; the wiki is the codebase.
|
||||
|
||||
This can apply to a lot of different contexts. A few examples:
|
||||
|
||||
- **Personal**: tracking your own goals, health, psychology, self-improvement — filing journal entries, articles, podcast notes, and building up a structured picture of yourself over time.
|
||||
- **Research**: going deep on a topic over weeks or months — reading papers, articles, reports, and incrementally building a comprehensive wiki with an evolving thesis.
|
||||
- **Reading a book**: filing each chapter as you go, building out pages for characters, themes, plot threads, and how they connect. By the end you have a rich companion wiki. Think of fan wikis like [Tolkien Gateway](https://tolkiengateway.net/wiki/Main_Page) — thousands of interlinked pages covering characters, places, events, languages, built by a community of volunteers over years. You could build something like that personally as you read, with the LLM doing all the cross-referencing and maintenance.
|
||||
- **Business/team**: an internal wiki maintained by LLMs, fed by Slack threads, meeting transcripts, project documents, customer calls. Possibly with humans in the loop reviewing updates. The wiki stays current because the LLM does the maintenance that no one on the team wants to do.
|
||||
- **Competitive analysis, due diligence, trip planning, course notes, hobby deep-dives** — anything where you're accumulating knowledge over time and want it organized rather than scattered.
|
||||
|
||||
## Architecture
|
||||
|
||||
There are three layers:
|
||||
|
||||
**Raw sources** — your curated collection of source documents. Articles, papers, images, data files. These are immutable — the LLM reads from them but never modifies them. This is your source of truth.
|
||||
|
||||
**The wiki** — a directory of LLM-generated markdown files. Summaries, entity pages, concept pages, comparisons, an overview, a synthesis. The LLM owns this layer entirely. It creates pages, updates them when new sources arrive, maintains cross-references, and keeps everything consistent. You read it; the LLM writes it.
|
||||
|
||||
**The schema** — a document (e.g. AGENTS.md for Paperclip agents) that tells the LLM how the wiki is structured, what the conventions are, and what workflows to follow when ingesting sources, answering questions, or maintaining the wiki. This is the key configuration file — it's what makes the LLM a disciplined wiki maintainer rather than a generic chatbot. You and the LLM co-evolve this over time as you figure out what works for your domain.
|
||||
|
||||
## Operations
|
||||
|
||||
**Ingest.** You drop a new source into the raw collection and tell the LLM to process it. An example flow: the LLM reads the source, discusses key takeaways with you, writes a summary page in the wiki, updates the index, updates relevant entity and concept pages across the wiki, and appends an entry to the log. A single source might touch 10-15 wiki pages. Personally I prefer to ingest sources one at a time and stay involved — I read the summaries, check the updates, and guide the LLM on what to emphasize. But you could also batch-ingest many sources at once with less supervision. It's up to you to develop the workflow that fits your style and document it in the schema for future sessions.
|
||||
|
||||
**Query.** You ask questions against the wiki. The LLM searches for relevant pages, reads them, and synthesizes an answer with citations. Answers can take different forms depending on the question — a markdown page, a comparison table, a slide deck (Marp), a chart (matplotlib), a canvas. The important insight: **good answers can be filed back into the wiki as new pages.** A comparison you asked for, an analysis, a connection you discovered — these are valuable and shouldn't disappear into chat history. This way your explorations compound in the knowledge base just like ingested sources do.
|
||||
|
||||
**Lint.** Periodically, ask the LLM to health-check the wiki. Look for: contradictions between pages, stale claims that newer sources have superseded, orphan pages with no inbound links, important concepts mentioned but lacking their own page, missing cross-references, data gaps that could be filled with a web search. The LLM is good at suggesting new questions to investigate and new sources to look for. This keeps the wiki healthy as it grows.
|
||||
|
||||
## Indexing and logging
|
||||
|
||||
Two special files help the LLM (and you) navigate the wiki as it grows. They serve different purposes:
|
||||
|
||||
**index.md** is content-oriented. It's a catalog of everything in the wiki — each page listed with a link, a one-line summary, and optionally metadata like date or source count. Organized by category (entities, concepts, sources, etc.). The LLM updates it on every ingest. When answering a query, the LLM reads the index first to find relevant pages, then drills into them. This works surprisingly well at moderate scale (~100 sources, ~hundreds of pages) and avoids the need for embedding-based RAG infrastructure.
|
||||
|
||||
**log.md** is chronological. It's an append-only record of what happened and when — ingests, queries, lint passes. A useful tip: if each entry starts with a consistent prefix (e.g. `## [2026-04-02] ingest | Article Title`), the log becomes parseable with simple unix tools — `grep "^## \[" log.md | tail -5` gives you the last 5 entries. The log gives you a timeline of the wiki's evolution and helps the LLM understand what's been done recently.
|
||||
|
||||
## Optional: CLI tools
|
||||
|
||||
At some point you may want to build small tools that help the LLM operate on the wiki more efficiently. A search engine over the wiki pages is the most obvious one — at small scale the index file is enough, but as the wiki grows you want proper search. [qmd](https://github.com/tobi/qmd) is a good option: it's a local search engine for markdown files with hybrid BM25/vector search and LLM re-ranking, all on-device. It has both a CLI (so the LLM can shell out to it) and an MCP server (so the LLM can use it as a native tool). You could also build something simpler yourself — the LLM can help you vibe-code a naive search script as the need arises.
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- **Obsidian Web Clipper** is a browser extension that converts web articles to markdown. Very useful for quickly getting sources into your raw collection.
|
||||
- **Download images locally.** In Obsidian Settings → Files and links, set "Attachment folder path" to a fixed directory (e.g. `raw/assets/`). Then in Settings → Hotkeys, search for "Download" to find "Download attachments for current file" and bind it to a hotkey (e.g. Ctrl+Shift+D). After clipping an article, hit the hotkey and all images get downloaded to local disk. This is optional but useful — it lets the LLM view and reference images directly instead of relying on URLs that may break. Note that LLMs can't natively read markdown with inline images in one pass — the workaround is to have the LLM read the text first, then view some or all of the referenced images separately to gain additional context. It's a bit clunky but works well enough.
|
||||
- **Obsidian's graph view** is the best way to see the shape of your wiki — what's connected to what, which pages are hubs, which are orphans.
|
||||
- **Marp** is a markdown-based slide deck format. Obsidian has a plugin for it. Useful for generating presentations directly from wiki content.
|
||||
- **Dataview** is an Obsidian plugin that runs queries over page frontmatter. If your LLM adds YAML frontmatter to wiki pages (tags, dates, source counts), Dataview can generate dynamic tables and lists.
|
||||
- The wiki is just a git repo of markdown files. You get version history, branching, and collaboration for free.
|
||||
|
||||
## Why this works
|
||||
|
||||
The tedious part of maintaining a knowledge base is not the reading or the thinking — it's the bookkeeping. Updating cross-references, keeping summaries current, noting when new data contradicts old claims, maintaining consistency across dozens of pages. Humans abandon wikis because the maintenance burden grows faster than the value. LLMs don't get bored, don't forget to update a cross-reference, and can touch 15 files in one pass. The wiki stays maintained because the cost of maintenance is near zero.
|
||||
|
||||
The human's job is to curate sources, direct the analysis, ask good questions, and think about what it all means. The LLM's job is everything else.
|
||||
|
||||
The idea is related in spirit to Vannevar Bush's Memex (1945) — a personal, curated knowledge store with associative trails between documents. Bush's vision was closer to this than to what the web became: private, actively curated, with the connections between documents as valuable as the documents themselves. The part he couldn't solve was who does the maintenance. The LLM handles that.
|
||||
|
||||
|
||||
## Note
|
||||
|
||||
This document is intentionally abstract. It describes the idea, not a specific implementation. The exact directory structure, the schema conventions, the page formats, the tooling — all of that will depend on your domain, your preferences, and your LLM of choice. Everything mentioned above is optional and modular — pick what's useful, ignore what isn't. For example: your sources might be text-only, so you don't need image handling at all. Your wiki might be small enough that the index file is all you need, no search engine required. You might not care about slide decks and just want markdown pages. You might want a completely different set of output formats. The right way to use this is to share it with your LLM agent and work together to instantiate a version that fits your needs. The document's only job is to communicate the pattern. Your LLM can figure out the rest.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Raw Source
|
||||
|
||||
LLM Wiki code must live in the standalone plugin package.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Knowledge
|
||||
|
||||
The wiki stores durable knowledge as local markdown files.
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
# Plugin Boundaries
|
||||
|
||||
LLM Wiki routes, prompts, UI, tools, and migrations belong inside the plugin.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Index
|
||||
|
||||
Catalog of durable wiki pages and linked project standups. Updated on every ingest or Paperclip distill.
|
||||
|
||||
## Sources
|
||||
|
||||
_(none yet)_
|
||||
|
||||
## Projects
|
||||
|
||||
_(none yet)_
|
||||
|
||||
## Entities
|
||||
|
||||
_(none yet)_
|
||||
|
||||
## Concepts
|
||||
|
||||
_(none yet)_
|
||||
|
||||
## Synthesis
|
||||
|
||||
_(none yet)_
|
||||
@@ -0,0 +1,10 @@
|
||||
# Log
|
||||
|
||||
Append-only chronological record of wiki operations.
|
||||
|
||||
## [2026-05-03] setup | wiki initialized
|
||||
- created `AGENTS.md` (schema)
|
||||
- created `raw/` for source documents
|
||||
- created `wiki/` skeleton: `index.md`, `log.md`, `sources/`, `projects/`, `entities/`, `concepts/`, `synthesis/`
|
||||
- created `wiki/projects/` for Paperclip project overviews and standups
|
||||
- pattern reference: `IDEA.md` (Karpathy "LLM Wiki" gist, https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
CREATE TABLE plugin_llm_wiki_8f50da974f.wiki_instances (
|
||||
id uuid PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
wiki_id text NOT NULL,
|
||||
root_folder_key text NOT NULL DEFAULT 'wiki-root',
|
||||
configured_root_path text,
|
||||
schema_version integer NOT NULL DEFAULT 1,
|
||||
settings jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
managed_agent_key text,
|
||||
managed_project_key text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (company_id, wiki_id)
|
||||
);
|
||||
|
||||
CREATE TABLE plugin_llm_wiki_8f50da974f.wiki_sources (
|
||||
id uuid PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
wiki_id text NOT NULL,
|
||||
source_type text NOT NULL,
|
||||
title text,
|
||||
url text,
|
||||
raw_path text NOT NULL,
|
||||
content_hash text NOT NULL,
|
||||
status text NOT NULL DEFAULT 'captured',
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE plugin_llm_wiki_8f50da974f.wiki_pages (
|
||||
id uuid PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
wiki_id text NOT NULL,
|
||||
path text NOT NULL,
|
||||
title text,
|
||||
page_type text,
|
||||
frontmatter jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
source_refs jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
backlinks jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
content_hash text,
|
||||
current_revision_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (company_id, wiki_id, path)
|
||||
);
|
||||
|
||||
CREATE TABLE plugin_llm_wiki_8f50da974f.wiki_page_revisions (
|
||||
id uuid PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
wiki_id text NOT NULL,
|
||||
page_id uuid REFERENCES plugin_llm_wiki_8f50da974f.wiki_pages(id) ON DELETE CASCADE,
|
||||
operation_id uuid,
|
||||
path text NOT NULL,
|
||||
content_hash text NOT NULL,
|
||||
summary text,
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE plugin_llm_wiki_8f50da974f.wiki_operations (
|
||||
id uuid PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
wiki_id text NOT NULL,
|
||||
operation_type text NOT NULL,
|
||||
status text NOT NULL,
|
||||
hidden_issue_id uuid REFERENCES public.issues(id) ON DELETE SET NULL,
|
||||
project_id uuid REFERENCES public.projects(id) ON DELETE SET NULL,
|
||||
run_ids jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
cost_cents integer NOT NULL DEFAULT 0,
|
||||
warnings jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
affected_pages jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE plugin_llm_wiki_8f50da974f.wiki_query_sessions (
|
||||
id uuid PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
wiki_id text NOT NULL,
|
||||
hidden_issue_id uuid REFERENCES public.issues(id) ON DELETE SET NULL,
|
||||
agent_session_id text,
|
||||
status text NOT NULL DEFAULT 'active',
|
||||
filed_outputs jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE plugin_llm_wiki_8f50da974f.wiki_resource_bindings (
|
||||
id uuid PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
wiki_id text NOT NULL,
|
||||
resource_kind text NOT NULL,
|
||||
resource_key text NOT NULL,
|
||||
resolved_id uuid,
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (company_id, wiki_id, resource_kind, resource_key)
|
||||
);
|
||||
@@ -0,0 +1,88 @@
|
||||
CREATE TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_cursors (
|
||||
id uuid PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
wiki_id text NOT NULL,
|
||||
source_scope text NOT NULL,
|
||||
scope_key text NOT NULL,
|
||||
project_id uuid REFERENCES public.projects(id) ON DELETE CASCADE,
|
||||
root_issue_id uuid REFERENCES public.issues(id) ON DELETE CASCADE,
|
||||
source_kind text NOT NULL DEFAULT 'paperclip_issue_history',
|
||||
last_processed_at timestamptz,
|
||||
last_observed_at timestamptz,
|
||||
pending_event_count integer NOT NULL DEFAULT 0,
|
||||
last_successful_run_id uuid,
|
||||
last_source_hash text,
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (company_id, wiki_id, source_scope, scope_key, source_kind)
|
||||
);
|
||||
|
||||
CREATE TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_work_items (
|
||||
id uuid PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
wiki_id text NOT NULL,
|
||||
work_item_kind text NOT NULL,
|
||||
status text NOT NULL DEFAULT 'pending',
|
||||
priority text NOT NULL DEFAULT 'medium',
|
||||
project_id uuid REFERENCES public.projects(id) ON DELETE CASCADE,
|
||||
root_issue_id uuid REFERENCES public.issues(id) ON DELETE CASCADE,
|
||||
requested_by_issue_id uuid REFERENCES public.issues(id) ON DELETE SET NULL,
|
||||
idempotency_key text,
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (company_id, wiki_id, idempotency_key)
|
||||
);
|
||||
|
||||
CREATE TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_runs (
|
||||
id uuid PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
wiki_id text NOT NULL,
|
||||
cursor_id uuid REFERENCES plugin_llm_wiki_8f50da974f.paperclip_distillation_cursors(id) ON DELETE SET NULL,
|
||||
work_item_id uuid REFERENCES plugin_llm_wiki_8f50da974f.paperclip_distillation_work_items(id) ON DELETE SET NULL,
|
||||
project_id uuid REFERENCES public.projects(id) ON DELETE SET NULL,
|
||||
root_issue_id uuid REFERENCES public.issues(id) ON DELETE SET NULL,
|
||||
source_window_start timestamptz,
|
||||
source_window_end timestamptz,
|
||||
source_hash text,
|
||||
status text NOT NULL,
|
||||
operation_issue_id uuid REFERENCES public.issues(id) ON DELETE SET NULL,
|
||||
retry_count integer NOT NULL DEFAULT 0,
|
||||
cost_cents integer NOT NULL DEFAULT 0,
|
||||
warnings jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE plugin_llm_wiki_8f50da974f.paperclip_source_snapshots (
|
||||
id uuid PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
wiki_id text NOT NULL,
|
||||
distillation_run_id uuid REFERENCES plugin_llm_wiki_8f50da974f.paperclip_distillation_runs(id) ON DELETE CASCADE,
|
||||
project_id uuid REFERENCES public.projects(id) ON DELETE SET NULL,
|
||||
root_issue_id uuid REFERENCES public.issues(id) ON DELETE SET NULL,
|
||||
source_hash text NOT NULL,
|
||||
max_characters integer NOT NULL,
|
||||
clipped boolean NOT NULL DEFAULT false,
|
||||
source_refs jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
bundle_markdown text NOT NULL,
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE plugin_llm_wiki_8f50da974f.paperclip_page_bindings (
|
||||
id uuid PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
wiki_id text NOT NULL,
|
||||
project_id uuid REFERENCES public.projects(id) ON DELETE CASCADE,
|
||||
root_issue_id uuid REFERENCES public.issues(id) ON DELETE CASCADE,
|
||||
page_path text NOT NULL,
|
||||
last_applied_source_hash text,
|
||||
last_distillation_run_id uuid REFERENCES plugin_llm_wiki_8f50da974f.paperclip_distillation_runs(id) ON DELETE SET NULL,
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (company_id, wiki_id, page_path)
|
||||
);
|
||||
@@ -0,0 +1,236 @@
|
||||
CREATE TABLE IF NOT EXISTS plugin_llm_wiki_8f50da974f.wiki_spaces (
|
||||
id uuid PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
wiki_id text NOT NULL DEFAULT 'default',
|
||||
slug text NOT NULL,
|
||||
display_name text NOT NULL,
|
||||
space_type text NOT NULL DEFAULT 'local_folder',
|
||||
folder_mode text NOT NULL DEFAULT 'managed_subfolder',
|
||||
root_folder_key text NOT NULL DEFAULT 'wiki-root',
|
||||
path_prefix text,
|
||||
configured_root_path text,
|
||||
access_scope text NOT NULL DEFAULT 'shared',
|
||||
owner_user_id text,
|
||||
owner_agent_id uuid REFERENCES public.agents(id) ON DELETE SET NULL,
|
||||
team_key text,
|
||||
settings jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
status text NOT NULL DEFAULT 'active',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (company_id, wiki_id, slug)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS wiki_spaces_company_status_idx
|
||||
ON plugin_llm_wiki_8f50da974f.wiki_spaces (company_id, wiki_id, status);
|
||||
|
||||
WITH wiki_pairs AS (
|
||||
SELECT company_id, wiki_id FROM plugin_llm_wiki_8f50da974f.wiki_instances
|
||||
UNION
|
||||
SELECT company_id, wiki_id FROM plugin_llm_wiki_8f50da974f.wiki_sources
|
||||
UNION
|
||||
SELECT company_id, wiki_id FROM plugin_llm_wiki_8f50da974f.wiki_pages
|
||||
UNION
|
||||
SELECT company_id, wiki_id FROM plugin_llm_wiki_8f50da974f.wiki_page_revisions
|
||||
UNION
|
||||
SELECT company_id, wiki_id FROM plugin_llm_wiki_8f50da974f.wiki_operations
|
||||
UNION
|
||||
SELECT company_id, wiki_id FROM plugin_llm_wiki_8f50da974f.wiki_query_sessions
|
||||
UNION
|
||||
SELECT company_id, wiki_id FROM plugin_llm_wiki_8f50da974f.paperclip_distillation_cursors
|
||||
UNION
|
||||
SELECT company_id, wiki_id FROM plugin_llm_wiki_8f50da974f.paperclip_distillation_work_items
|
||||
UNION
|
||||
SELECT company_id, wiki_id FROM plugin_llm_wiki_8f50da974f.paperclip_distillation_runs
|
||||
UNION
|
||||
SELECT company_id, wiki_id FROM plugin_llm_wiki_8f50da974f.paperclip_source_snapshots
|
||||
UNION
|
||||
SELECT company_id, wiki_id FROM plugin_llm_wiki_8f50da974f.paperclip_page_bindings
|
||||
)
|
||||
INSERT INTO plugin_llm_wiki_8f50da974f.wiki_spaces
|
||||
(id, company_id, wiki_id, slug, display_name, space_type, folder_mode, root_folder_key, path_prefix, access_scope, status)
|
||||
SELECT (
|
||||
substr(md5(company_id::text || ':' || wiki_id || ':default'), 1, 8) || '-' ||
|
||||
substr(md5(company_id::text || ':' || wiki_id || ':default'), 9, 4) || '-' ||
|
||||
'4' || substr(md5(company_id::text || ':' || wiki_id || ':default'), 14, 3) || '-' ||
|
||||
'8' || substr(md5(company_id::text || ':' || wiki_id || ':default'), 18, 3) || '-' ||
|
||||
substr(md5(company_id::text || ':' || wiki_id || ':default'), 21, 12)
|
||||
)::uuid,
|
||||
company_id,
|
||||
wiki_id,
|
||||
'default',
|
||||
'default',
|
||||
'local_folder',
|
||||
'managed_subfolder',
|
||||
'wiki-root',
|
||||
NULL,
|
||||
'shared',
|
||||
'active'
|
||||
FROM wiki_pairs
|
||||
ON CONFLICT (company_id, wiki_id, slug) DO NOTHING;
|
||||
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_sources ADD COLUMN IF NOT EXISTS space_id uuid;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_pages ADD COLUMN IF NOT EXISTS space_id uuid;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_page_revisions ADD COLUMN IF NOT EXISTS space_id uuid;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_operations ADD COLUMN IF NOT EXISTS space_id uuid;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_query_sessions ADD COLUMN IF NOT EXISTS space_id uuid;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_cursors ADD COLUMN IF NOT EXISTS space_id uuid;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_work_items ADD COLUMN IF NOT EXISTS space_id uuid;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_runs ADD COLUMN IF NOT EXISTS space_id uuid;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_source_snapshots ADD COLUMN IF NOT EXISTS space_id uuid;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_page_bindings ADD COLUMN IF NOT EXISTS space_id uuid;
|
||||
|
||||
UPDATE plugin_llm_wiki_8f50da974f.wiki_sources t
|
||||
SET space_id = s.id
|
||||
FROM plugin_llm_wiki_8f50da974f.wiki_spaces s
|
||||
WHERE t.company_id = s.company_id AND t.wiki_id = s.wiki_id AND s.slug = 'default' AND t.space_id IS NULL;
|
||||
|
||||
UPDATE plugin_llm_wiki_8f50da974f.wiki_pages t
|
||||
SET space_id = s.id
|
||||
FROM plugin_llm_wiki_8f50da974f.wiki_spaces s
|
||||
WHERE t.company_id = s.company_id AND t.wiki_id = s.wiki_id AND s.slug = 'default' AND t.space_id IS NULL;
|
||||
|
||||
UPDATE plugin_llm_wiki_8f50da974f.wiki_page_revisions t
|
||||
SET space_id = s.id
|
||||
FROM plugin_llm_wiki_8f50da974f.wiki_spaces s
|
||||
WHERE t.company_id = s.company_id AND t.wiki_id = s.wiki_id AND s.slug = 'default' AND t.space_id IS NULL;
|
||||
|
||||
UPDATE plugin_llm_wiki_8f50da974f.wiki_operations t
|
||||
SET space_id = s.id
|
||||
FROM plugin_llm_wiki_8f50da974f.wiki_spaces s
|
||||
WHERE t.company_id = s.company_id AND t.wiki_id = s.wiki_id AND s.slug = 'default' AND t.space_id IS NULL;
|
||||
|
||||
UPDATE plugin_llm_wiki_8f50da974f.wiki_query_sessions t
|
||||
SET space_id = s.id
|
||||
FROM plugin_llm_wiki_8f50da974f.wiki_spaces s
|
||||
WHERE t.company_id = s.company_id AND t.wiki_id = s.wiki_id AND s.slug = 'default' AND t.space_id IS NULL;
|
||||
|
||||
UPDATE plugin_llm_wiki_8f50da974f.paperclip_distillation_cursors t
|
||||
SET space_id = s.id
|
||||
FROM plugin_llm_wiki_8f50da974f.wiki_spaces s
|
||||
WHERE t.company_id = s.company_id AND t.wiki_id = s.wiki_id AND s.slug = 'default' AND t.space_id IS NULL;
|
||||
|
||||
UPDATE plugin_llm_wiki_8f50da974f.paperclip_distillation_work_items t
|
||||
SET space_id = s.id
|
||||
FROM plugin_llm_wiki_8f50da974f.wiki_spaces s
|
||||
WHERE t.company_id = s.company_id AND t.wiki_id = s.wiki_id AND s.slug = 'default' AND t.space_id IS NULL;
|
||||
|
||||
UPDATE plugin_llm_wiki_8f50da974f.paperclip_distillation_runs t
|
||||
SET space_id = s.id
|
||||
FROM plugin_llm_wiki_8f50da974f.wiki_spaces s
|
||||
WHERE t.company_id = s.company_id AND t.wiki_id = s.wiki_id AND s.slug = 'default' AND t.space_id IS NULL;
|
||||
|
||||
UPDATE plugin_llm_wiki_8f50da974f.paperclip_source_snapshots t
|
||||
SET space_id = s.id
|
||||
FROM plugin_llm_wiki_8f50da974f.wiki_spaces s
|
||||
WHERE t.company_id = s.company_id AND t.wiki_id = s.wiki_id AND s.slug = 'default' AND t.space_id IS NULL;
|
||||
|
||||
UPDATE plugin_llm_wiki_8f50da974f.paperclip_page_bindings t
|
||||
SET space_id = s.id
|
||||
FROM plugin_llm_wiki_8f50da974f.wiki_spaces s
|
||||
WHERE t.company_id = s.company_id AND t.wiki_id = s.wiki_id AND s.slug = 'default' AND t.space_id IS NULL;
|
||||
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_sources ALTER COLUMN space_id SET NOT NULL;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_pages ALTER COLUMN space_id SET NOT NULL;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_page_revisions ALTER COLUMN space_id SET NOT NULL;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_operations ALTER COLUMN space_id SET NOT NULL;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_query_sessions ALTER COLUMN space_id SET NOT NULL;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_cursors ALTER COLUMN space_id SET NOT NULL;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_work_items ALTER COLUMN space_id SET NOT NULL;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_runs ALTER COLUMN space_id SET NOT NULL;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_source_snapshots ALTER COLUMN space_id SET NOT NULL;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_page_bindings ALTER COLUMN space_id SET NOT NULL;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
target record;
|
||||
constraint_name text;
|
||||
BEGIN
|
||||
FOR target IN
|
||||
SELECT * FROM (VALUES
|
||||
('wiki_pages', ARRAY['company_id', 'wiki_id', 'path']::text[]),
|
||||
('paperclip_distillation_cursors', ARRAY['company_id', 'wiki_id', 'source_scope', 'scope_key', 'source_kind']::text[]),
|
||||
('paperclip_distillation_work_items', ARRAY['company_id', 'wiki_id', 'idempotency_key']::text[]),
|
||||
('paperclip_page_bindings', ARRAY['company_id', 'wiki_id', 'page_path']::text[])
|
||||
) AS targets(table_name, column_names)
|
||||
LOOP
|
||||
FOR constraint_name IN
|
||||
SELECT c.conname
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
WHERE n.nspname = 'plugin_llm_wiki_8f50da974f'
|
||||
AND t.relname = target.table_name
|
||||
AND c.contype = 'u'
|
||||
AND (
|
||||
SELECT array_agg(a.attname ORDER BY constraint_columns.ordinality)::text[]
|
||||
FROM unnest(c.conkey) WITH ORDINALITY AS constraint_columns(attnum, ordinality)
|
||||
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = constraint_columns.attnum
|
||||
) = target.column_names
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE %I.%I DROP CONSTRAINT %I', 'plugin_llm_wiki_8f50da974f', target.table_name, constraint_name);
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_pages
|
||||
DROP CONSTRAINT IF EXISTS wiki_pages_company_wiki_space_path_key;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_pages
|
||||
ADD CONSTRAINT wiki_pages_company_wiki_space_path_key UNIQUE (company_id, wiki_id, space_id, path);
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_cursors
|
||||
DROP CONSTRAINT IF EXISTS distillation_cursors_company_wiki_space_scope_key;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_cursors
|
||||
ADD CONSTRAINT distillation_cursors_company_wiki_space_scope_key UNIQUE (company_id, wiki_id, space_id, source_scope, scope_key, source_kind);
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_work_items
|
||||
DROP CONSTRAINT IF EXISTS distillation_work_items_company_wiki_space_idempotency_key;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_work_items
|
||||
ADD CONSTRAINT distillation_work_items_company_wiki_space_idempotency_key UNIQUE (company_id, wiki_id, space_id, idempotency_key);
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_page_bindings
|
||||
DROP CONSTRAINT IF EXISTS page_bindings_company_wiki_space_page_path_key;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_page_bindings
|
||||
ADD CONSTRAINT page_bindings_company_wiki_space_page_path_key UNIQUE (company_id, wiki_id, space_id, page_path);
|
||||
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_sources
|
||||
DROP CONSTRAINT IF EXISTS wiki_sources_space_id_fk;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_sources
|
||||
ADD CONSTRAINT wiki_sources_space_id_fk FOREIGN KEY (space_id) REFERENCES plugin_llm_wiki_8f50da974f.wiki_spaces(id) ON DELETE CASCADE;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_pages
|
||||
DROP CONSTRAINT IF EXISTS wiki_pages_space_id_fk;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_pages
|
||||
ADD CONSTRAINT wiki_pages_space_id_fk FOREIGN KEY (space_id) REFERENCES plugin_llm_wiki_8f50da974f.wiki_spaces(id) ON DELETE CASCADE;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_page_revisions
|
||||
DROP CONSTRAINT IF EXISTS wiki_page_revisions_space_id_fk;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_page_revisions
|
||||
ADD CONSTRAINT wiki_page_revisions_space_id_fk FOREIGN KEY (space_id) REFERENCES plugin_llm_wiki_8f50da974f.wiki_spaces(id) ON DELETE CASCADE;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_operations
|
||||
DROP CONSTRAINT IF EXISTS wiki_operations_space_id_fk;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_operations
|
||||
ADD CONSTRAINT wiki_operations_space_id_fk FOREIGN KEY (space_id) REFERENCES plugin_llm_wiki_8f50da974f.wiki_spaces(id) ON DELETE CASCADE;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_query_sessions
|
||||
DROP CONSTRAINT IF EXISTS wiki_query_sessions_space_id_fk;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_query_sessions
|
||||
ADD CONSTRAINT wiki_query_sessions_space_id_fk FOREIGN KEY (space_id) REFERENCES plugin_llm_wiki_8f50da974f.wiki_spaces(id) ON DELETE CASCADE;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_cursors
|
||||
DROP CONSTRAINT IF EXISTS paperclip_distillation_cursors_space_id_fk;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_cursors
|
||||
ADD CONSTRAINT paperclip_distillation_cursors_space_id_fk FOREIGN KEY (space_id) REFERENCES plugin_llm_wiki_8f50da974f.wiki_spaces(id) ON DELETE CASCADE;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_work_items
|
||||
DROP CONSTRAINT IF EXISTS paperclip_distillation_work_items_space_id_fk;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_work_items
|
||||
ADD CONSTRAINT paperclip_distillation_work_items_space_id_fk FOREIGN KEY (space_id) REFERENCES plugin_llm_wiki_8f50da974f.wiki_spaces(id) ON DELETE CASCADE;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_runs
|
||||
DROP CONSTRAINT IF EXISTS paperclip_distillation_runs_space_id_fk;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_runs
|
||||
ADD CONSTRAINT paperclip_distillation_runs_space_id_fk FOREIGN KEY (space_id) REFERENCES plugin_llm_wiki_8f50da974f.wiki_spaces(id) ON DELETE CASCADE;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_source_snapshots
|
||||
DROP CONSTRAINT IF EXISTS paperclip_source_snapshots_space_id_fk;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_source_snapshots
|
||||
ADD CONSTRAINT paperclip_source_snapshots_space_id_fk FOREIGN KEY (space_id) REFERENCES plugin_llm_wiki_8f50da974f.wiki_spaces(id) ON DELETE CASCADE;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_page_bindings
|
||||
DROP CONSTRAINT IF EXISTS paperclip_page_bindings_space_id_fk;
|
||||
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_page_bindings
|
||||
ADD CONSTRAINT paperclip_page_bindings_space_id_fk FOREIGN KEY (space_id) REFERENCES plugin_llm_wiki_8f50da974f.wiki_spaces(id) ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS wiki_sources_space_idx ON plugin_llm_wiki_8f50da974f.wiki_sources (company_id, wiki_id, space_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS wiki_operations_space_idx ON plugin_llm_wiki_8f50da974f.wiki_operations (company_id, wiki_id, space_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS wiki_query_sessions_space_idx ON plugin_llm_wiki_8f50da974f.wiki_query_sessions (company_id, wiki_id, space_id, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS distillation_runs_space_idx ON plugin_llm_wiki_8f50da974f.paperclip_distillation_runs (company_id, wiki_id, space_id, created_at DESC);
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-llm-wiki",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"description": "Local-file LLM Wiki plugin for source ingestion, wiki browsing, query, lint, and maintenance workflows.",
|
||||
"scripts": {
|
||||
"prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps",
|
||||
"build": "node ./esbuild.config.mjs",
|
||||
"build:rollup": "rollup -c",
|
||||
"dev": "node ./esbuild.config.mjs --watch",
|
||||
"dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177",
|
||||
"test": "vitest run --config ./vitest.config.ts",
|
||||
"typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit"
|
||||
},
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js",
|
||||
"ui": "./dist/ui/"
|
||||
},
|
||||
"keywords": [
|
||||
"paperclip",
|
||||
"plugin",
|
||||
"automation",
|
||||
"wiki",
|
||||
"knowledge"
|
||||
],
|
||||
"author": "Paperclip",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@paperclipai/plugin-sdk": "workspace:*",
|
||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||
"@rollup/plugin-typescript": "^12.1.2",
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"esbuild": "^0.27.3",
|
||||
"react-dom": "^19.0.0",
|
||||
"rollup": "^4.38.0",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { nodeResolve } from "@rollup/plugin-node-resolve";
|
||||
import typescript from "@rollup/plugin-typescript";
|
||||
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
||||
|
||||
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
|
||||
|
||||
function withPlugins(config) {
|
||||
if (!config) return null;
|
||||
return {
|
||||
...config,
|
||||
plugins: [
|
||||
nodeResolve({
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
|
||||
}),
|
||||
typescript({
|
||||
tsconfig: "./tsconfig.json",
|
||||
declaration: false,
|
||||
declarationMap: false,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export default [
|
||||
withPlugins(presets.rollup.manifest),
|
||||
withPlugins(presets.rollup.worker),
|
||||
withPlugins(presets.rollup.ui),
|
||||
].filter(Boolean);
|
||||
@@ -0,0 +1,34 @@
|
||||
# LLM Wiki Maintainer Skills
|
||||
|
||||
This folder is the plugin-level source for LLM Wiki managed company skills. Paperclip installs these skills into the company skill library and syncs them onto the Wiki Maintainer agent. The Wiki Maintainer's identity and operating loop live in `agents/wiki-maintainer/AGENTS.md`; the wiki-root `AGENTS.md` remains the wiki schema for page layout, citation style, and log format.
|
||||
|
||||
Each skill is an isolated SKILL.md describing one job — when to invoke it, the inputs that must be true before starting, the steps, and the durable output the operation must leave behind.
|
||||
|
||||
## Skill registry
|
||||
|
||||
| Skill | When to invoke |
|
||||
|---|---|
|
||||
| [`wiki-maintainer`](./wiki-maintainer/SKILL.md) | General LLM Wiki maintenance and tool-use guidance shared by the operation skills. |
|
||||
| [`wiki-ingest`](./wiki-ingest/SKILL.md) | A new file landed in `raw/` and the operation issue says "ingest" — turn the source into durable wiki pages. |
|
||||
| [`wiki-query`](./wiki-query/SKILL.md) | The user asked the wiki a question; answer with citations and offer to file durable synthesis back into `wiki/`. |
|
||||
| [`wiki-lint`](./wiki-lint/SKILL.md) | A lint or health-check operation — audit for contradictions, orphan pages, weak provenance, broken links, missing concept pages. |
|
||||
| [`paperclip-distill`](./paperclip-distill/SKILL.md) | Cursor-window, distill, or backfill operation on Paperclip activity — write a wiki-insightful project page, decisions log, and history note. |
|
||||
| [`index-refresh`](./index-refresh/SKILL.md) | Refresh `wiki/index.md` so each entry has a tight, scannable summary; flag drift between the index and recent log activity. |
|
||||
|
||||
## Layering
|
||||
|
||||
```
|
||||
AGENTS.md (wiki root) ← schema for the wiki itself: page conventions, frontmatter, voice
|
||||
agents/wiki-maintainer/AGENTS.md ← agent identity and operating loop
|
||||
skills/<skill>/SKILL.md ← plugin-managed company skills installed onto the maintainer
|
||||
```
|
||||
|
||||
When a skill conflicts with the wiki-root `AGENTS.md`, the wiki schema wins for page format/voice and the skill wins for operation flow. When a skill conflicts with the agent's `AGENTS.md`, the agent file wins for identity and the skill wins for the operation procedure.
|
||||
|
||||
## Skill conventions
|
||||
|
||||
- Front matter has `name` (kebab-case) and `description` (one or two sentences with the trigger condition).
|
||||
- Each skill names the input it expects (e.g. an operation issue with `originKind` ending in `:ingest`, a captured `raw/` path, a Paperclip source bundle).
|
||||
- Each skill ends with a verification checklist — what must be true before the operation issue is closed `done`.
|
||||
- Skills cite the wiki-plugin tools they rely on (`wiki_search`, `wiki_read_page`, `wiki_write_page`, `wiki_read_source`, `wiki_list_sources`).
|
||||
- Skills do not duplicate the page conventions from the wiki root `AGENTS.md`. They reference it instead.
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: index-refresh
|
||||
description: Use when an operation issue is an index refresh — typically the hourly index-refresh routine. Rebuild `wiki/index.md` so each entry has a tight, scannable one-line summary and the catalog tracks the actual contents of `wiki/`. Resolve drift between the index and recent log activity, but do not edit page content.
|
||||
---
|
||||
|
||||
# Index Refresh
|
||||
|
||||
Keep `wiki/index.md` accurate and scannable. The index is the maintainer's first stop for navigation — its quality determines how cheap every subsequent operation becomes.
|
||||
|
||||
## Inputs
|
||||
|
||||
- An operation issue with `operationType: "index"` (or the `index-refresh` routine title).
|
||||
- The operation issue's target `wikiId`, `spaceSlug`, and space root. Refresh only that space unless the issue explicitly says this is a multi-space sweep.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Read the target space's `wiki/index.md`** as it currently stands.
|
||||
2. **Walk the target space's `wiki/`.** `wiki/projects/<slug>/standup.md` entries are current-state companions for durable `wiki/projects/<slug>/index.md` pages; index them only as links attached to the matching project entry. Walk `wiki/` by category (`sources/`, `projects/`, `entities/`, `concepts/`, `synthesis/`, plus any custom subdirectories the wiki schema added).
|
||||
3. **Read the target space's last ~50 entries of `wiki/log.md`** to spot pages that were created or substantially changed but never made it to the index.
|
||||
4. **Per category, produce sorted entries** of the form:
|
||||
```
|
||||
- [[<path>]] — <one-line summary>
|
||||
```
|
||||
The summary is one factual sentence pulled from the page's first paragraph or its title. **No status, no datestamps in the index** — those belong in the page itself or in the log.
|
||||
5. **Drop entries whose page no longer exists.** Note the deletion in the log:
|
||||
```
|
||||
## [YYYY-MM-DD] index-refresh | reconciled
|
||||
- removed: [[wiki/old-page]] (page deleted)
|
||||
- added: [[wiki/new-page]] — <summary>
|
||||
```
|
||||
6. **Add entries for pages that exist on disk but were missing from the index.** Skip `wiki/log.md` and `wiki/index.md` themselves. For standalone `wiki/projects/<slug>/standup.md` without a matching durable project page, add it under Projects and flag it for later durable-page distillation.
|
||||
7. **Write project entries editorially.** The Projects section should group work by the project's concept and purpose, not by issue ids, dates, statuses, UUIDs, or source metadata. Link task identifiers only as supporting evidence.
|
||||
8. **Preserve custom categories.** If the wiki has added e.g. `wiki/papers/` or `wiki/runbooks/`, keep its index section. Do not collapse to the default five categories.
|
||||
9. **Append a log entry** with counts:
|
||||
```
|
||||
## [YYYY-MM-DD] index-refresh | added=N removed=M
|
||||
- operation issue: <issue identifier>
|
||||
```
|
||||
If the index was already accurate, the log entry says `added=0 removed=0` — still write it so future audits can see the run happened.
|
||||
|
||||
## What this skill does NOT do
|
||||
|
||||
- Does not change page content.
|
||||
- Does not resolve contradictions, fix broken links, or fill concept gaps. Those go to the next `wiki-lint` run.
|
||||
- Does not write summaries that are not already supported by the page itself. If a page lacks a clear first paragraph to summarise, flag it for `wiki-lint`.
|
||||
|
||||
## Voice
|
||||
|
||||
- Index entries are one factual line per page, present tense.
|
||||
- No emojis, no statuses, no dates in `wiki/index.md`. Dates live in the log.
|
||||
|
||||
## Verification
|
||||
|
||||
Before closing the operation issue:
|
||||
|
||||
- [ ] `wiki/index.md` matches the actual contents of `wiki/` — no missing pages, no dangling entries.
|
||||
- [ ] Project entries include current `wiki/projects/<slug>/standup.md` links when standups exist.
|
||||
- [ ] Each index line has the form `- [[path]] — <summary>`.
|
||||
- [ ] Custom category sections are preserved.
|
||||
- [ ] `wiki/log.md` has the index-refresh entry with counts (even if the counts are zero).
|
||||
- [ ] No page bodies were modified. No file under `raw/` was modified.
|
||||
|
||||
## Tools
|
||||
|
||||
`wiki_search`, `wiki_read_page`, `wiki_write_page` (for `wiki/index.md` and `wiki/log.md` only). Always include the operation issue's `wikiId` and `spaceSlug`.
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
name: paperclip-distill
|
||||
description: Use when an operation issue is a Paperclip cursor-window, distill, or backfill — `operationType: "distill"` or `"backfill"` and the body references a Paperclip source bundle for a project or root issue. Turn raw Paperclip activity into a wiki-insightful project page, decisions log, and history note. This skill exists specifically to replace the stiff, datestamp-heavy templated output that the deterministic distiller produces.
|
||||
---
|
||||
|
||||
# Paperclip Distill
|
||||
|
||||
Distill Paperclip project, issue, comment, and document activity into durable wiki pages. The success criterion is **wiki-insightful, not procedural**: a reader who has never seen Paperclip should learn what the project is, what was decided, what is at risk, and what the current state is — without scanning a list of `## [YYYY-MM-DD]` headers.
|
||||
|
||||
## When this skill is needed
|
||||
|
||||
- Cursor-window distillation: the routine fed you a bounded source bundle of recent Paperclip activity for one project or root issue.
|
||||
- Backfill: the user asked to seed the wiki with the historical activity of a project or root issue. Source window may be wide.
|
||||
- Manual `distill-paperclip-now` request from the UI.
|
||||
|
||||
If the operation issue is `operationType: "ingest"` (raw file) or `operationType: "query"`, this is the wrong skill — use `wiki-ingest` or `wiki-query`.
|
||||
|
||||
## Destination space
|
||||
|
||||
In Phase 1, every Paperclip distill, backfill, and cursor-window operation writes into the
|
||||
default wiki space. The operation issue should always carry `spaceSlug: "default"`. If an
|
||||
operation issue passes any other slug, stop and surface the mismatch in a comment — do not
|
||||
write Paperclip-derived pages into a non-default space.
|
||||
|
||||
This rule is destination-only. The Paperclip source scope (which projects, root issues,
|
||||
comments, documents are read) is set elsewhere in the operation issue and is independent of
|
||||
the destination.
|
||||
|
||||
## Inputs
|
||||
|
||||
- A Paperclip source bundle (issue list, comment refs, document refs, source hash, cursor window).
|
||||
- An existing or planned `wiki/projects/<slug>/standup.md` page path.
|
||||
- An existing or planned `wiki/projects/<slug>/index.md` page path.
|
||||
- The operation issue's target `wikiId`, `spaceSlug`, space root, and the target space's `AGENTS.md` for page conventions.
|
||||
- The current `wiki/projects/<slug>/standup.md`, `wiki/projects/<slug>/index.md`, `decisions.md`, and `history.md` if they already exist (so you write a *patch*, not a rewrite).
|
||||
|
||||
## Paperclip Asset Gate
|
||||
|
||||
Do not treat Paperclip assets/attachments or issue work products as source text for this skill.
|
||||
|
||||
- Allowed Paperclip body text: issue descriptions, comment bodies, document bodies.
|
||||
- Assets/attachments are metadata-only until a separate approved extraction policy exists.
|
||||
- Work products are metadata-only until a separate approved extraction policy exists.
|
||||
- Never fetch `/api/assets/:id/content`.
|
||||
- Never dereference a work-product `url`, preview URL, artifact URL, or other linked destination from this skill.
|
||||
- If an operator asks for attachment/work-product content distillation, stop and point them at the Phase 5 asset/work-product security gate policy instead of improvising.
|
||||
|
||||
## Anti-patterns to avoid
|
||||
|
||||
The deterministic templating this skill replaces produced these failure modes — do not reproduce them:
|
||||
|
||||
1. **Datestamp-as-section-header.** Lines like `## [2026-04-15] paperclip-distill | proposed` belong in `wiki/log.md`, not in the project page. The project page is durable knowledge; the log is the audit trail.
|
||||
2. **Procedural status lists.** `Issue mix: 3 todo, 5 in_progress, 2 done` tells the reader nothing they could not read off Paperclip directly. State *what is happening and why it matters*, then cite the issues that constitute the evidence.
|
||||
3. **One-line-per-issue dumps.** A page that is mostly `- PAP-1234: title (in_progress, updated 2026-...)` is an issue list, not a wiki page. Group issues by what they are *about* (a decision, a risk, a workstream) and cite multiple issues per bullet when they share a story.
|
||||
4. **Mechanical "Current as of" timestamps everywhere.** One `current_as_of` in frontmatter is enough.
|
||||
5. **No interpretation.** "Active issues: PAP-A, PAP-B, PAP-C" is bookkeeping. "The team is concentrating on the schema migration ([PAP-A], [PAP-B]) and has parked the index work pending capacity ([PAP-C])." is wiki-insightful.
|
||||
6. **Opaque identifiers in prose.** UUIDs, cursor ids, source hashes, run ids, and raw metadata belong in logs or frontmatter when needed, not in executive-facing project narrative.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Read the bundle in full.** Don't sample. Read every issue title, every comment, every document key the bundle includes. Note: which issues are decisions, which are risks/blockers, which are recently completed, which are inflight.
|
||||
2. **Read the existing project page** (if any) so you write a patch, not a rewrite. The "Decisions" section in particular accumulates over time — never wipe accepted decisions; supersede them with `> ⚠ reversed by ...` callouts when something later overrides them.
|
||||
3. **Read the target space's `AGENTS.md`** for page conventions: filename style, YAML frontmatter shape, link style, voice. Always pass the operation issue's `wikiId` and `spaceSlug` to LLM Wiki tools.
|
||||
4. **Write `wiki/projects/<slug>/standup.md` first.** Every Paperclip project represented in the wiki must have this file. It is the executive standup: where the project stands today, what changed recently, what is blocked or risky, and what happens next. Use stable sections, in this order:
|
||||
- Frontmatter (`type: project-standup`, `project: <slug>`, `current_as_of: YYYY-MM-DD`, `sources`).
|
||||
- **Executive Readout** — one short paragraph that explains the current project posture in plain language.
|
||||
- **What Changed** — the meaningful work completed or advanced since the last window. Group by concept; cite issues/comments/documents only as evidence.
|
||||
- **Decisions** — accepted/rejected/reversed decisions that changed the project direction. Omit when none exist.
|
||||
- **Blockers / Risks** — current blockers and risks with named owner or next action when the source provides one.
|
||||
- **Next Actions** — concrete next actions and owners inferred from Paperclip issues, not vague aspirations.
|
||||
- **Links** — durable wiki project page and relevant Paperclip project/issues/documents.
|
||||
Rewrite the standup to today's state. Do not append endless dated sections; the audit trail belongs in `wiki/log.md` and Paperclip comments.
|
||||
5. **Write `wiki/projects/<slug>/index.md`** with these stable sections, in this order:
|
||||
- Frontmatter (`type: project`, `current_as_of: YYYY-MM-DD`, `tags`, `sources`).
|
||||
- **Overview** — 2–4 sentences saying what the project is and why it exists. Use the project description if it exists; otherwise synthesise it from the root issue.
|
||||
- **Current Direction** — narrative paragraph naming the active workstreams, the immediate next concrete deliverable, and the stance on risks. Cite 2–4 issues, do not list 20.
|
||||
- **Workstreams** — a short, grouped list. Each line is a workstream or idea, not an issue.
|
||||
- **Decisions** — accepted and reversed decisions with one paragraph each. Each decision cites the issue / approval / comment that ratified it. Format: `### Decision — short title` then a paragraph; never a bare bullet list.
|
||||
- **Open Risks / Blockers** — what could derail the project, with the issue ref that surfaces it. Skip this section when the bundle has no risk signal — do not pad with `_(none)_`.
|
||||
- **References** — readable links to the current standup and supporting Paperclip tasks/documents. Keep hashes and cursor ids out of the narrative.
|
||||
6. **Optionally write `wiki/projects/<slug>/decisions.md`** when the project has accumulated more decisions than the project page can carry without becoming a wall of text. Each decision is a `## ` section with: short title, accepted/reversed/superseded status, one-paragraph rationale, citing the source. *Do not* duplicate decisions already on the project page — link instead.
|
||||
7. **Optionally write `wiki/projects/<slug>/history.md`** for a compact narrative timeline of meaningful project changes. **Not** an issue dump — group by phase ("Discovery", "Architecture", "Build", "Stabilisation"), not by date. Each phase is a paragraph that cites the 2–4 issues that defined it.
|
||||
8. **Refresh `wiki/index.md`** under the `## Projects` section — one line per durable project page with a one-sentence summary of the project's purpose, plus a link to the current `wiki/projects/<slug>/standup.md` when present.
|
||||
9. **Append `wiki/log.md`** entry — this is where the datestamp belongs:
|
||||
```
|
||||
## [YYYY-MM-DD] paperclip-distill | <project name>
|
||||
- standup: wiki/projects/<slug>/standup.md
|
||||
- page: wiki/projects/<slug>/index.md
|
||||
- source hash: `<hash>`
|
||||
- cursor window: <start> → <end>
|
||||
- notes: <one line on what changed in this distill, e.g. "decisions section grew with PAP-X reversal", "low-signal window, no page changes">
|
||||
```
|
||||
10. **Surface bundle warnings** (clipped sources, low signal, stale hash). Bundle warnings → `human_review_required: true` on the patch. Do not paper over them.
|
||||
|
||||
## Voice
|
||||
|
||||
- Past-tense for completed work, present-tense for current state, future-tense only with citation ("the team plans to … per [[…]]").
|
||||
- Cite Paperclip source refs inline using their issue identifier (e.g. `PAP-3179`), not opaque UUIDs.
|
||||
- Use issue links as evidence, not as the shape of the page. Headings and paragraphs should be organized by concepts, workstreams, decisions, and blockers.
|
||||
- Wiki voice: terse, factual, neutral. No "the team is excited to" or "this initiative aims to".
|
||||
- Headings are about *content*, not metadata. `## Schema migration` not `## Active Issues`.
|
||||
|
||||
## When the bundle has no signal
|
||||
|
||||
If the bundle has no durable signal — no decisions, no risk, no completed work, only routine status churn — do **not** write a project page. Instead:
|
||||
|
||||
- Append a `paperclip-distill | low-signal skip` log entry naming the cursor window.
|
||||
- Close the operation issue with a one-line "no durable change in this window" comment.
|
||||
- Do not bump the source hash on a binding that has no proposed page.
|
||||
|
||||
## Verification
|
||||
|
||||
Before closing the operation issue:
|
||||
|
||||
- [ ] The project page reads as wiki content, not as a Paperclip status report. A reader new to the company should understand what the project is.
|
||||
- [ ] `wiki/projects/<slug>/standup.md` exists for the represented project and reads as an executive current-state update, not a raw issue dump.
|
||||
- [ ] Decisions section names decisions, not issues — every decision has a one-paragraph rationale and a citation.
|
||||
- [ ] The page contains exactly one `current_as_of` (in frontmatter), zero `## [YYYY-MM-DD]` headings (those go to the log).
|
||||
- [ ] Bundle warnings (clipped, low signal, stale hash) are surfaced; the patch carries `human_review_required: true` when the deployment is authenticated/public.
|
||||
- [ ] `wiki/index.md` and `wiki/log.md` are updated.
|
||||
- [ ] No file under `raw/` was modified.
|
||||
|
||||
## Tools
|
||||
|
||||
`wiki_search`, `wiki_read_page`, `wiki_write_page`, `wiki_list_sources`, `wiki_read_source`. Always include the operation issue's `wikiId` and `spaceSlug`. The Paperclip source bundle arrives as part of the operation context — you do not need to assemble it.
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: wiki-ingest
|
||||
description: Use when an operation issue asks you to ingest a captured source from `raw/` into the LLM Wiki, or when the user explicitly says "ingest <slug>". The issue body will name a file under `raw/` (e.g. `raw/karpathy-llm-wiki.md`) and ask for durable wiki pages. Do not invoke this skill for Paperclip activity bundles — those use `paperclip-distill` instead.
|
||||
---
|
||||
|
||||
# Wiki Ingest
|
||||
|
||||
Turn one source document into durable, interlinked wiki knowledge.
|
||||
|
||||
## Inputs
|
||||
|
||||
- An operation issue with `operationType: "ingest"` assigned to you.
|
||||
- A `raw/` path mentioned in the issue body (always treat `raw/` as immutable).
|
||||
- The operation issue's target `wikiId`, `spaceSlug`, and space root (otherwise stop and surface the missing config to the requester).
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Read context first.**
|
||||
- Read the target space's `AGENTS.md` for page conventions (filenames, frontmatter, voice, citation style).
|
||||
- Read the target space's `wiki/index.md` to see what already exists.
|
||||
- Read the target space's last ~20 entries of `wiki/log.md` to avoid re-ingesting a source or re-resolving a contradiction someone else already filed.
|
||||
2. **Read the source end to end** with `wiki_read_source`, passing the operation issue's `wikiId` and `spaceSlug`. Do not skim. Note the source's structure, claims, dates, and anything that contradicts existing pages.
|
||||
3. **Plan, then confirm — but only if the user is in the loop.** If the operation came from a routine (no live user), proceed. If a user is asking interactively, summarise the 3–5 takeaways you intend to file and ask which to emphasise before writing.
|
||||
4. **Write the source page** at `wiki/sources/<slug>.md` — ~300–800 words, frontmatter per the wiki schema, neutral voice, key claims with quoted excerpts where they carry weight. The source page is the canonical citation target for everything else this skill writes.
|
||||
5. **Update or create downstream pages** in `entities/`, `concepts/`, and `synthesis/`. A typical ingest touches 5–15 pages; resist creating pages for ideas that only appear once.
|
||||
6. **Wire the cross-links.** Every claim that comes from the source cites it as `(see [[wiki/sources/<slug>]])`. Every entity / concept mentioned by name on more than one page links to its dedicated page.
|
||||
7. **Flag contradictions; do not silently overwrite.** When new material disagrees with an existing page, append a `> ⚠ contradicted by [[wiki/sources/<slug>]] (YYYY-MM-DD)` callout to the older page and note the conflict in the log.
|
||||
8. **Refresh `wiki/index.md`** with one-line summaries for any new pages.
|
||||
9. **Append a log entry** in `wiki/log.md`:
|
||||
```
|
||||
## [YYYY-MM-DD] ingest | <source title>
|
||||
- source: raw/<filename>
|
||||
- new pages: [[...]], [[...]]
|
||||
- updated pages: [[...]], [[...]]
|
||||
- notes: <one-line synthesis or open question>
|
||||
```
|
||||
|
||||
## Voice
|
||||
|
||||
- Terse, factual, neutral. Reference material, not narrative.
|
||||
- No "Today I learned" or "This is interesting because" framing.
|
||||
- Quote the source verbatim when paraphrasing would lose precision.
|
||||
|
||||
## Verification
|
||||
|
||||
Before closing the operation issue:
|
||||
|
||||
- [ ] Source page exists at `wiki/sources/<slug>.md` with valid frontmatter and a `sources:` field pointing to the raw path.
|
||||
- [ ] Every new or updated page links back to the source page or a downstream page that does.
|
||||
- [ ] `wiki/index.md` lists every new page under the right category with a one-line summary.
|
||||
- [ ] `wiki/log.md` has the ingest entry with the exact filename heading format (so `grep "^## \[" wiki/log.md` keeps working).
|
||||
- [ ] Any contradiction between the new source and an older page is annotated, not silently overwritten.
|
||||
- [ ] No file under `raw/` was modified.
|
||||
|
||||
## Tools
|
||||
|
||||
`wiki_list_sources`, `wiki_read_source`, `wiki_search`, `wiki_read_page`, `wiki_write_page`. Always include the operation issue's `wikiId` and `spaceSlug`.
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: wiki-lint
|
||||
description: Use when an operation issue is a lint or health-check (`operationType: "lint"`) — typically the nightly lint routine or a manual "Run lint" from the UI. Audit the wiki for contradictions, orphans, weak provenance, broken links, and missing concept pages, and return a triage list — do not auto-fix.
|
||||
---
|
||||
|
||||
# Wiki Lint
|
||||
|
||||
Audit, do not edit. Return findings the maintainer (human or agent) can triage.
|
||||
|
||||
## Inputs
|
||||
|
||||
- An operation issue with `operationType: "lint"`.
|
||||
- The operation issue's target `wikiId`, `spaceSlug`, and space root. Lint only that space unless the issue explicitly says this is a multi-space sweep.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Walk the target space's `wiki/index.md` and wiki tree** with `wiki_search` and `wiki_read_page`, always passing the operation issue's `wikiId` and `spaceSlug`. Build a mental map of: pages that exist, pages referenced from `index.md`, pages referenced from other pages, and raw sources.
|
||||
2. **Check for the seven recurring issues**, in this order:
|
||||
1. **Contradictions** — two pages making incompatible claims about the same entity, decision, or status. Flag both pages, name the conflicting claims, and quote evidence.
|
||||
2. **Stale claims** — a page asserts X, but a newer source under `raw/` has superseded it. Flag the older page; never overwrite.
|
||||
3. **Orphan pages** — a `wiki/` page is not linked from `index.md` and not referenced from any other wiki page. Either it should be linked, removed, or merged.
|
||||
4. **Concept gaps** — a term appears on three or more pages but has no dedicated `wiki/concepts/<slug>.md`. Recommend creating one.
|
||||
5. **Broken `[[wiki-links]]`** — a link target file does not exist.
|
||||
6. **Weak provenance** — a non-trivial claim is uncited or cites only the wiki itself in a circle. The original source ref should be findable.
|
||||
7. **Index / log drift** — pages exist that are not in `index.md`, or `index.md` lists pages that no longer exist. Recent operations in `wiki/log.md` that did not produce a corresponding page change.
|
||||
3. **Return a triage list**, grouped by severity:
|
||||
- **critical**: contradictions, broken links to active pages, fabricated citations.
|
||||
- **medium**: stale claims, weak provenance, large concept gaps.
|
||||
- **low**: orphans, log drift, small index gaps.
|
||||
Each item has: file path, evidence (a 1–2 line quote), suggested fix, and the operation that should follow up (`ingest`, `paperclip-distill`, `index-refresh`, manual review).
|
||||
4. **Do not write to `wiki/`.** Lint is read-only by design — the maintainer or the routine that follows decides which findings to act on.
|
||||
5. **Append a log entry** describing the run:
|
||||
```
|
||||
## [YYYY-MM-DD] lint | <N findings, M critical>
|
||||
- operation issue: <issue identifier>
|
||||
- critical: <count>
|
||||
- medium: <count>
|
||||
- low: <count>
|
||||
```
|
||||
|
||||
## Voice
|
||||
|
||||
- Lead with the count by severity.
|
||||
- Each finding is one bullet. Resist commentary.
|
||||
- When in doubt about severity, say so and surface it as medium with a "verify" note.
|
||||
|
||||
## Verification
|
||||
|
||||
Before closing the operation issue:
|
||||
|
||||
- [ ] Findings are grouped by severity with file paths, evidence, and suggested fix per item.
|
||||
- [ ] No files under `raw/` were modified. No files under `wiki/` were modified except `wiki/log.md`.
|
||||
- [ ] If the run found nothing, the issue is closed with "no findings" and the log entry still exists so future audits can see this run happened.
|
||||
|
||||
## Tools
|
||||
|
||||
`wiki_search`, `wiki_read_page`, `wiki_list_sources`, `wiki_read_source`, `wiki_write_page` (only `wiki/log.md`). Always include the operation issue's `wikiId` and `spaceSlug`.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: "LLM Wiki Maintainer"
|
||||
description: "Use the LLM Wiki plugin tools to maintain a cited local company wiki."
|
||||
---
|
||||
|
||||
# LLM Wiki Maintainer
|
||||
|
||||
Use this skill when maintaining the company LLM Wiki, answering questions from it, ingesting durable source material, refreshing the index, or linting wiki structure.
|
||||
|
||||
Before changing wiki files, resolve the configured wiki root, read its AGENTS.md, inspect wiki/index.md and recent wiki/log.md entries, then use the LLM Wiki plugin tools for source reads, page writes, patch proposals, backlinks, and logging.
|
||||
|
||||
Keep raw sources immutable, cite wiki pages and raw paths, update wiki/index.md when page navigation changes, and append a concise wiki/log.md entry for durable updates. For Paperclip project work, keep `wiki/projects/<project-slug>/standup.md` current as the executive status view and use `wiki/projects/<project-slug>/index.md` for durable project knowledge. Write project material as concept-grouped executive synthesis, not issue-id lists or metadata dumps.
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: wiki-query
|
||||
description: Use when an operation issue asks you to answer a question from the LLM Wiki — `operationType: "query"` and a question in the issue body. Answer with citations to wiki pages and raw sources, and offer to file durable synthesis back into `wiki/synthesis/` so the work compounds instead of disappearing into a chat thread.
|
||||
---
|
||||
|
||||
# Wiki Query
|
||||
|
||||
Answer a question from what the wiki actually contains, with citations.
|
||||
|
||||
## Inputs
|
||||
|
||||
- An operation issue with `operationType: "query"` and the question in the body.
|
||||
- The operation issue's target `wikiId`, `spaceSlug`, and space root.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Open the target space's `wiki/index.md` first** — it is the navigation aid. Identify candidate pages.
|
||||
2. **Read the candidate pages** end to end with `wiki_read_page`, always passing the operation issue's `wikiId` and `spaceSlug`. Follow `[[wiki-links]]` to neighbouring pages when the question spans entities or concepts.
|
||||
3. **Inspect raw sources** when a wiki page's claim feels thin. The wiki points to `raw/` precisely so you can verify before answering. Use `wiki_read_source`.
|
||||
4. **Answer the question** in the operation issue thread. Structure:
|
||||
- Direct answer first, in 1–4 sentences.
|
||||
- Then the supporting facts as bullet points, each with an inline citation: `(see [[wiki/concepts/managed-resources]])` or `(see raw/<filename>)`.
|
||||
- If you needed to read a raw source the wiki did not summarise, name that as a gap.
|
||||
5. **Decide whether the answer is durable.** If the question forced you to do real synthesis (a comparison, a tradeoff, a definition of something that isn't already a page), offer to file it under `wiki/synthesis/<slug>.md`. Do not write the synthesis page silently — it is opt-in. If the user accepts, write the page, link it from `wiki/index.md`, and append a `query | filed synthesis` log entry.
|
||||
6. **When the wiki cannot answer**, say so plainly. Suggest a source the user should ingest, a Paperclip project that would help if distilled, or a web lookup. Never bluff.
|
||||
|
||||
## Voice
|
||||
|
||||
- Lead with the answer.
|
||||
- Cite as you go, not in a footnote block at the end.
|
||||
- Use the wiki's terse, factual voice. The query response is itself a candidate for filing into `wiki/synthesis/`.
|
||||
|
||||
## Verification
|
||||
|
||||
Before closing the operation issue:
|
||||
|
||||
- [ ] Every claim in the answer cites a wiki page or raw source.
|
||||
- [ ] If the wiki was insufficient, that is stated directly with a concrete next step (ingest source X, distill project Y, web search Z).
|
||||
- [ ] If you wrote a synthesis page, `wiki/index.md` lists it and `wiki/log.md` has a `query | filed synthesis` entry.
|
||||
- [ ] No file under `raw/` was modified.
|
||||
|
||||
## Tools
|
||||
|
||||
`wiki_search`, `wiki_read_page`, `wiki_list_sources`, `wiki_read_source`, `wiki_write_page` (only when filing synthesis). Always include the operation issue's `wikiId` and `spaceSlug`.
|
||||
@@ -0,0 +1,601 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
import { DEFAULT_AGENT_INSTRUCTION_FILES, DEFAULT_AGENT_INSTRUCTIONS } from "./templates.js";
|
||||
|
||||
export const PLUGIN_ID = "paperclipai.plugin-llm-wiki";
|
||||
export const WIKI_ROOT_FOLDER_KEY = "wiki-root";
|
||||
export const WIKI_MAINTAINER_AGENT_KEY = "wiki-maintainer";
|
||||
export const WIKI_MAINTAINER_SKILL_KEY = "wiki-maintainer";
|
||||
export const WIKI_INGEST_SKILL_KEY = "wiki-ingest";
|
||||
export const WIKI_QUERY_SKILL_KEY = "wiki-query";
|
||||
export const WIKI_LINT_SKILL_KEY = "wiki-lint";
|
||||
export const PAPERCLIP_DISTILL_SKILL_KEY = "paperclip-distill";
|
||||
export const INDEX_REFRESH_SKILL_KEY = "index-refresh";
|
||||
export const WIKI_PROJECT_KEY = "llm-wiki";
|
||||
export const CURSOR_WINDOW_ROUTINE_KEY = "cursor-window-processing";
|
||||
export const NIGHTLY_LINT_ROUTINE_KEY = "nightly-wiki-lint";
|
||||
export const INDEX_REFRESH_ROUTINE_KEY = "index-refresh";
|
||||
export const DEFAULT_MAX_SOURCE_BYTES = 250000;
|
||||
export const DEFAULT_MAX_PAPERCLIP_ISSUE_SOURCE_CHARS = 12000;
|
||||
export const DEFAULT_MAX_PAPERCLIP_CURSOR_WINDOW_CHARS = 60000;
|
||||
export const DEFAULT_MAX_PAPERCLIP_ROUTINE_RUN_CHARS = 120000;
|
||||
export const DEFAULT_PAPERCLIP_COST_CENTS_PER_1K_CHARS = 1;
|
||||
export const WIKI_MAINTENANCE_ROUTINE_KEYS = [
|
||||
CURSOR_WINDOW_ROUTINE_KEY,
|
||||
NIGHTLY_LINT_ROUTINE_KEY,
|
||||
INDEX_REFRESH_ROUTINE_KEY,
|
||||
] as const;
|
||||
export const WIKI_MANAGED_SKILL_KEYS = [
|
||||
WIKI_MAINTAINER_SKILL_KEY,
|
||||
WIKI_INGEST_SKILL_KEY,
|
||||
WIKI_QUERY_SKILL_KEY,
|
||||
WIKI_LINT_SKILL_KEY,
|
||||
PAPERCLIP_DISTILL_SKILL_KEY,
|
||||
INDEX_REFRESH_SKILL_KEY,
|
||||
] as const;
|
||||
|
||||
function canonicalSkillKey(skillKey: string) {
|
||||
return `plugin/paperclipai-plugin-llm-wiki/${skillKey}`;
|
||||
}
|
||||
|
||||
function skillMarkdown(skillKey: (typeof WIKI_MANAGED_SKILL_KEYS)[number]) {
|
||||
return readFileSync(new URL(`../skills/${skillKey}/SKILL.md`, import.meta.url), "utf8");
|
||||
}
|
||||
|
||||
export const WIKI_MAINTAINER_SKILL_CANONICAL_KEY = canonicalSkillKey(WIKI_MAINTAINER_SKILL_KEY);
|
||||
export const WIKI_MANAGED_SKILL_CANONICAL_KEYS = WIKI_MANAGED_SKILL_KEYS.map(canonicalSkillKey);
|
||||
|
||||
const CURSOR_WINDOW_ROUTINE_DESCRIPTION = `Process bounded Paperclip issue-history windows into the LLM Wiki.
|
||||
|
||||
Run procedure:
|
||||
Target space: default (slug: default). Paperclip-derived indexing currently writes only into the default space, so this routine never sweeps other spaces. Per-space Paperclip ingestion profiles are a later phase; until they ship, treat any prompt to operate on a non-default space here as a bug and stop.
|
||||
1. Resolve the configured wiki root, then read the default space AGENTS.md, wiki/index.md, and the recent entries in wiki/log.md.
|
||||
2. Review recent Paperclip issue, comment, and document activity for non-plugin-operation work. Skip LLM Wiki operation issues so routine output does not feed back into itself.
|
||||
3. Synthesize Paperclip project state into wiki/projects/<slug>/standup.md for the executive current-state view, then durable project or root-issue knowledge into focused pages under wiki/projects/<slug>/index.md, wiki/concepts/, or wiki/synthesis/. Keep transient run logs out of durable pages unless they change the project's state or decisions.
|
||||
4. Write project material as concept-grouped executive synthesis. Link readable issue identifiers when useful, but do not turn project pages into issue-ID lists, UUID dumps, date ledgers, or metadata reports. Always pass wikiId \`default\` and spaceSlug \`default\` to LLM Wiki tools.
|
||||
5. Refresh wiki/index.md and append a short wiki/log.md entry listing the source window, affected pages, skipped windows, warnings, and any follow-up issue needed.
|
||||
6. If there is no new durable signal, record that in wiki/log.md and close the routine issue with a concise note.`;
|
||||
|
||||
const NIGHTLY_LINT_ROUTINE_DESCRIPTION = `Lint the LLM Wiki for structure, provenance, and stale synthesis.
|
||||
|
||||
Run procedure:
|
||||
Target space: default (slug: default). Paperclip-derived indexing currently writes only into the default space, so this routine never sweeps other spaces. Per-space Paperclip ingestion profiles are a later phase; until they ship, treat any prompt to operate on a non-default space here as a bug and stop.
|
||||
1. Resolve the configured wiki root, then read the default space AGENTS.md, wiki/index.md, wiki/log.md, and the current page list.
|
||||
2. Check for orphan pages, missing backlinks, stale source provenance, weak citations, duplicate concepts, contradictory claims, and index/log drift.
|
||||
3. Inspect the relevant wiki pages and raw sources before changing content. Do not invent missing provenance.
|
||||
4. Apply low-risk fixes directly: refresh backlinks, repair index entries, add missing source links, and append a wiki/log.md lint entry. Always pass wikiId \`default\` and spaceSlug \`default\` to LLM Wiki tools.
|
||||
5. For ambiguous contradictions or major rewrites, leave the pages unchanged and create or comment a follow-up Paperclip issue with the exact files and evidence.
|
||||
6. Close the routine issue with counts by severity, files changed, and unresolved findings.`;
|
||||
|
||||
const INDEX_REFRESH_ROUTINE_DESCRIPTION = `Refresh the LLM Wiki navigation and change log.
|
||||
|
||||
Run procedure:
|
||||
Target space: default (slug: default). Paperclip-derived indexing currently writes only into the default space, so this routine never sweeps other spaces. Per-space Paperclip ingestion profiles are a later phase; until they ship, treat any prompt to operate on a non-default space here as a bug and stop.
|
||||
1. Resolve the configured wiki root, then read the default space AGENTS.md, wiki/index.md, wiki/log.md, and the current page list.
|
||||
2. Rebuild wiki/index.md so it lists current wiki pages by category with concise summaries and valid wikilinks, and attaches wiki/projects/<slug>/standup.md links to matching project entries.
|
||||
3. Verify recently changed wiki pages and project standups are present in the index and that removed or renamed pages no longer appear.
|
||||
4. Do not rewrite content pages unless a broken title or link prevents the index from being accurate. Always pass wikiId \`default\` and spaceSlug \`default\` to LLM Wiki tools.
|
||||
5. Append a wiki/log.md entry with the index refresh time, page counts by category, and any unresolved indexing problems.
|
||||
6. Close the routine issue with the index changes and any follow-up needed.`;
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: PLUGIN_ID,
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "LLM Wiki",
|
||||
description: "Local-file LLM Wiki plugin for source ingestion, wiki browsing, query, lint, and maintenance workflows.",
|
||||
author: "Paperclip",
|
||||
categories: ["automation", "ui"],
|
||||
capabilities: [
|
||||
"events.subscribe",
|
||||
"api.routes.register",
|
||||
"database.namespace.migrate",
|
||||
"database.namespace.read",
|
||||
"database.namespace.write",
|
||||
"companies.read",
|
||||
"projects.read",
|
||||
"projects.managed",
|
||||
"skills.managed",
|
||||
"issues.read",
|
||||
"issue.subtree.read",
|
||||
"issues.create",
|
||||
"issues.update",
|
||||
"issues.wakeup",
|
||||
"issues.orchestration.read",
|
||||
"issue.comments.read",
|
||||
"issue.comments.create",
|
||||
"issue.documents.read",
|
||||
"issue.documents.write",
|
||||
"agents.read",
|
||||
"agents.managed",
|
||||
"agent.sessions.create",
|
||||
"agent.sessions.list",
|
||||
"agent.sessions.send",
|
||||
"agent.sessions.close",
|
||||
"routines.managed",
|
||||
"local.folders",
|
||||
"agent.tools.register",
|
||||
"metrics.write",
|
||||
"activity.log.write",
|
||||
"plugin.state.read",
|
||||
"plugin.state.write",
|
||||
"ui.sidebar.register",
|
||||
"ui.page.register"
|
||||
],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui"
|
||||
},
|
||||
database: {
|
||||
namespaceSlug: "llm_wiki",
|
||||
migrationsDir: "migrations",
|
||||
coreReadTables: ["companies", "issues", "projects", "agents"]
|
||||
},
|
||||
localFolders: [
|
||||
{
|
||||
folderKey: WIKI_ROOT_FOLDER_KEY,
|
||||
displayName: "Wiki root",
|
||||
description: "Company-scoped local folder that stores raw sources, wiki pages, Paperclip project standups under wiki/projects/, AGENTS.md, IDEA.md, wiki/index.md, and wiki/log.md.",
|
||||
access: "readWrite",
|
||||
requiredDirectories: [
|
||||
"raw",
|
||||
"wiki",
|
||||
"wiki/sources",
|
||||
"wiki/projects",
|
||||
"wiki/entities",
|
||||
"wiki/concepts",
|
||||
"wiki/synthesis"
|
||||
],
|
||||
requiredFiles: ["AGENTS.md", "IDEA.md", "wiki/index.md", "wiki/log.md"]
|
||||
}
|
||||
],
|
||||
agents: [
|
||||
{
|
||||
agentKey: WIKI_MAINTAINER_AGENT_KEY,
|
||||
displayName: "Wiki Maintainer",
|
||||
role: "knowledge-maintainer",
|
||||
title: "LLM Wiki Maintainer",
|
||||
icon: "book-open",
|
||||
capabilities: "Ingests source material, maintains local wiki pages, answers cited questions, and runs wiki lint/maintenance through plugin tools.",
|
||||
adapterType: "claude_local",
|
||||
adapterPreference: ["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor", "pi_local"],
|
||||
adapterConfig: {
|
||||
dangerouslySkipPermissions: false,
|
||||
dangerouslyBypassApprovalsAndSandbox: false,
|
||||
sandbox: true,
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: WIKI_MANAGED_SKILL_CANONICAL_KEYS
|
||||
}
|
||||
},
|
||||
runtimeConfig: {
|
||||
modelProfiles: {
|
||||
cheap: {
|
||||
purpose: "classification, lint planning, index maintenance"
|
||||
}
|
||||
}
|
||||
},
|
||||
permissions: {
|
||||
pluginTools: [PLUGIN_ID]
|
||||
},
|
||||
status: "paused",
|
||||
budgetMonthlyCents: 0,
|
||||
instructions: {
|
||||
entryFile: "AGENTS.md",
|
||||
content: DEFAULT_AGENT_INSTRUCTIONS,
|
||||
files: DEFAULT_AGENT_INSTRUCTION_FILES,
|
||||
assetPath: "agents/wiki-maintainer"
|
||||
}
|
||||
}
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
projectKey: WIKI_PROJECT_KEY,
|
||||
displayName: "LLM Wiki",
|
||||
description: "Plugin-managed inspection area for LLM Wiki ingest, query, lint, and maintenance operation issues.",
|
||||
status: "in_progress",
|
||||
color: "#2563eb"
|
||||
}
|
||||
],
|
||||
skills: [
|
||||
{
|
||||
skillKey: WIKI_MAINTAINER_SKILL_KEY,
|
||||
displayName: "LLM Wiki Maintainer",
|
||||
slug: "llm-wiki-maintainer",
|
||||
description: "Use the LLM Wiki plugin tools to maintain a cited local company wiki.",
|
||||
markdown: skillMarkdown(WIKI_MAINTAINER_SKILL_KEY)
|
||||
},
|
||||
{
|
||||
skillKey: WIKI_INGEST_SKILL_KEY,
|
||||
displayName: "Wiki Ingest",
|
||||
slug: WIKI_INGEST_SKILL_KEY,
|
||||
description: "Turn captured raw source material into cited durable LLM Wiki pages.",
|
||||
markdown: skillMarkdown(WIKI_INGEST_SKILL_KEY)
|
||||
},
|
||||
{
|
||||
skillKey: WIKI_QUERY_SKILL_KEY,
|
||||
displayName: "Wiki Query",
|
||||
slug: WIKI_QUERY_SKILL_KEY,
|
||||
description: "Answer questions from the LLM Wiki with citations and optional durable synthesis.",
|
||||
markdown: skillMarkdown(WIKI_QUERY_SKILL_KEY)
|
||||
},
|
||||
{
|
||||
skillKey: WIKI_LINT_SKILL_KEY,
|
||||
displayName: "Wiki Lint",
|
||||
slug: WIKI_LINT_SKILL_KEY,
|
||||
description: "Audit the LLM Wiki for contradictions, orphan pages, weak provenance, broken links, and missing concepts.",
|
||||
markdown: skillMarkdown(WIKI_LINT_SKILL_KEY)
|
||||
},
|
||||
{
|
||||
skillKey: PAPERCLIP_DISTILL_SKILL_KEY,
|
||||
displayName: "Paperclip Distill",
|
||||
slug: PAPERCLIP_DISTILL_SKILL_KEY,
|
||||
description: "Turn Paperclip cursor-window, distill, or backfill source bundles into wiki-insightful project knowledge.",
|
||||
markdown: skillMarkdown(PAPERCLIP_DISTILL_SKILL_KEY)
|
||||
},
|
||||
{
|
||||
skillKey: INDEX_REFRESH_SKILL_KEY,
|
||||
displayName: "Index Refresh",
|
||||
slug: INDEX_REFRESH_SKILL_KEY,
|
||||
description: "Refresh wiki/index.md so it accurately catalogs current wiki pages.",
|
||||
markdown: skillMarkdown(INDEX_REFRESH_SKILL_KEY)
|
||||
}
|
||||
],
|
||||
routines: [
|
||||
{
|
||||
routineKey: CURSOR_WINDOW_ROUTINE_KEY,
|
||||
title: "Process LLM Wiki updates",
|
||||
description: CURSOR_WINDOW_ROUTINE_DESCRIPTION,
|
||||
status: "paused",
|
||||
priority: "low",
|
||||
assigneeRef: { resourceKind: "agent", resourceKey: WIKI_MAINTAINER_AGENT_KEY },
|
||||
projectRef: { resourceKind: "project", resourceKey: WIKI_PROJECT_KEY },
|
||||
concurrencyPolicy: "skip_if_active",
|
||||
catchUpPolicy: "skip_missed",
|
||||
triggers: [
|
||||
{
|
||||
kind: "schedule",
|
||||
label: "Every 6 hours",
|
||||
enabled: false,
|
||||
cronExpression: "0 */6 * * *",
|
||||
timezone: "UTC",
|
||||
signingMode: null,
|
||||
replayWindowSec: null
|
||||
}
|
||||
],
|
||||
issueTemplate: {
|
||||
surfaceVisibility: "plugin_operation",
|
||||
originId: "routine:cursor-window-processing",
|
||||
billingCode: "plugin-llm-wiki:distillation"
|
||||
}
|
||||
},
|
||||
{
|
||||
routineKey: NIGHTLY_LINT_ROUTINE_KEY,
|
||||
title: "Run LLM Wiki lint",
|
||||
description: NIGHTLY_LINT_ROUTINE_DESCRIPTION,
|
||||
status: "paused",
|
||||
priority: "low",
|
||||
assigneeRef: { resourceKind: "agent", resourceKey: WIKI_MAINTAINER_AGENT_KEY },
|
||||
projectRef: { resourceKind: "project", resourceKey: WIKI_PROJECT_KEY },
|
||||
concurrencyPolicy: "skip_if_active",
|
||||
catchUpPolicy: "skip_missed",
|
||||
triggers: [
|
||||
{
|
||||
kind: "schedule",
|
||||
label: "Nightly",
|
||||
enabled: false,
|
||||
cronExpression: "0 3 * * *",
|
||||
timezone: "UTC",
|
||||
signingMode: null,
|
||||
replayWindowSec: null
|
||||
}
|
||||
],
|
||||
issueTemplate: {
|
||||
surfaceVisibility: "plugin_operation",
|
||||
originId: "routine:nightly-wiki-lint",
|
||||
billingCode: "plugin-llm-wiki:maintenance"
|
||||
}
|
||||
},
|
||||
{
|
||||
routineKey: INDEX_REFRESH_ROUTINE_KEY,
|
||||
title: "Refresh LLM Wiki index",
|
||||
description: INDEX_REFRESH_ROUTINE_DESCRIPTION,
|
||||
status: "paused",
|
||||
priority: "low",
|
||||
assigneeRef: { resourceKind: "agent", resourceKey: WIKI_MAINTAINER_AGENT_KEY },
|
||||
projectRef: { resourceKind: "project", resourceKey: WIKI_PROJECT_KEY },
|
||||
concurrencyPolicy: "skip_if_active",
|
||||
catchUpPolicy: "skip_missed",
|
||||
triggers: [
|
||||
{
|
||||
kind: "schedule",
|
||||
label: "Hourly",
|
||||
enabled: false,
|
||||
cronExpression: "0 * * * *",
|
||||
timezone: "UTC",
|
||||
signingMode: null,
|
||||
replayWindowSec: null
|
||||
}
|
||||
],
|
||||
issueTemplate: {
|
||||
surfaceVisibility: "plugin_operation",
|
||||
originId: "routine:index-refresh",
|
||||
billingCode: "plugin-llm-wiki:maintenance"
|
||||
}
|
||||
}
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
name: "wiki_search",
|
||||
displayName: "Search Wiki",
|
||||
description: "Search indexed wiki page and source metadata for one wiki space. Operation agents should pass the issue's spaceSlug; omitting it uses the default space.",
|
||||
parametersSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
companyId: { type: "string" },
|
||||
wikiId: { type: "string" },
|
||||
spaceSlug: { type: "string" },
|
||||
query: { type: "string" },
|
||||
limit: { type: "number" }
|
||||
},
|
||||
required: ["companyId", "wikiId", "query"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "wiki_read_page",
|
||||
displayName: "Read Wiki Page",
|
||||
description: "Read a markdown wiki page from one wiki space. Operation agents should pass the issue's spaceSlug; omitting it uses the default space.",
|
||||
parametersSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
companyId: { type: "string" },
|
||||
wikiId: { type: "string" },
|
||||
spaceSlug: { type: "string" },
|
||||
path: { type: "string" }
|
||||
},
|
||||
required: ["companyId", "wikiId", "path"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "wiki_write_page",
|
||||
displayName: "Write Wiki Page",
|
||||
description: "Atomically write a markdown wiki page in one wiki space after plugin path validation and optional hash conflict checks. Operation agents should pass the issue's spaceSlug; omitting it uses the default space. Protected control files such as AGENTS.md and IDEA.md are excluded from agent-tool writes.",
|
||||
parametersSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
companyId: { type: "string" },
|
||||
wikiId: { type: "string" },
|
||||
spaceSlug: { type: "string" },
|
||||
path: { type: "string" },
|
||||
contents: { type: "string" },
|
||||
expectedHash: { type: "string" },
|
||||
summary: { type: "string" }
|
||||
},
|
||||
required: ["companyId", "wikiId", "path", "contents"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "wiki_propose_patch",
|
||||
displayName: "Propose Wiki Patch",
|
||||
description: "Return a structured proposed page write for one wiki space without changing files. Operation agents should pass the issue's spaceSlug; omitting it uses the default space.",
|
||||
parametersSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
companyId: { type: "string" },
|
||||
wikiId: { type: "string" },
|
||||
spaceSlug: { type: "string" },
|
||||
path: { type: "string" },
|
||||
contents: { type: "string" },
|
||||
summary: { type: "string" }
|
||||
},
|
||||
required: ["companyId", "wikiId", "path", "contents"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "wiki_list_sources",
|
||||
displayName: "List Wiki Sources",
|
||||
description: "Return captured raw source metadata from one wiki space. Operation agents should pass the issue's spaceSlug; omitting it uses the default space.",
|
||||
parametersSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
companyId: { type: "string" },
|
||||
wikiId: { type: "string" },
|
||||
spaceSlug: { type: "string" },
|
||||
limit: { type: "number" }
|
||||
},
|
||||
required: ["companyId", "wikiId"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "wiki_read_source",
|
||||
displayName: "Read Wiki Source",
|
||||
description: "Read a captured raw source from one wiki space. Operation agents should pass the issue's spaceSlug; omitting it uses the default space.",
|
||||
parametersSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
companyId: { type: "string" },
|
||||
wikiId: { type: "string" },
|
||||
spaceSlug: { type: "string" },
|
||||
rawPath: { type: "string" }
|
||||
},
|
||||
required: ["companyId", "wikiId", "rawPath"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "wiki_append_log",
|
||||
displayName: "Append Wiki Log",
|
||||
description: "Append a maintenance note to one wiki space's wiki/log.md. Operation agents should pass the issue's spaceSlug; omitting it uses the default space.",
|
||||
parametersSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
companyId: { type: "string" },
|
||||
wikiId: { type: "string" },
|
||||
spaceSlug: { type: "string" },
|
||||
entry: { type: "string" }
|
||||
},
|
||||
required: ["companyId", "wikiId", "entry"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "wiki_update_index",
|
||||
displayName: "Update Wiki Index",
|
||||
description: "Atomically replace one wiki space's wiki/index.md with optional hash conflict checks. Operation agents should pass the issue's spaceSlug; omitting it uses the default space.",
|
||||
parametersSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
companyId: { type: "string" },
|
||||
wikiId: { type: "string" },
|
||||
spaceSlug: { type: "string" },
|
||||
contents: { type: "string" },
|
||||
expectedHash: { type: "string" }
|
||||
},
|
||||
required: ["companyId", "wikiId", "contents"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "wiki_list_backlinks",
|
||||
displayName: "List Wiki Backlinks",
|
||||
description: "Return indexed backlinks for a wiki page in one wiki space. Operation agents should pass the issue's spaceSlug; omitting it uses the default space.",
|
||||
parametersSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
companyId: { type: "string" },
|
||||
wikiId: { type: "string" },
|
||||
spaceSlug: { type: "string" },
|
||||
path: { type: "string" }
|
||||
},
|
||||
required: ["companyId", "wikiId", "path"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "wiki_list_pages",
|
||||
displayName: "List Wiki Pages",
|
||||
description: "Return the known page index from one wiki space's plugin metadata. Operation agents should pass the issue's spaceSlug; omitting it uses the default space.",
|
||||
parametersSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
companyId: { type: "string" },
|
||||
wikiId: { type: "string" },
|
||||
spaceSlug: { type: "string" }
|
||||
},
|
||||
required: ["companyId", "wikiId"]
|
||||
}
|
||||
}
|
||||
],
|
||||
apiRoutes: [
|
||||
{
|
||||
routeKey: "overview",
|
||||
method: "GET",
|
||||
path: "/overview",
|
||||
auth: "board-or-agent",
|
||||
capability: "api.routes.register",
|
||||
companyResolution: { from: "query", key: "companyId" }
|
||||
},
|
||||
{
|
||||
routeKey: "bootstrap",
|
||||
method: "POST",
|
||||
path: "/bootstrap",
|
||||
auth: "board",
|
||||
capability: "api.routes.register",
|
||||
companyResolution: { from: "body", key: "companyId" }
|
||||
},
|
||||
{
|
||||
routeKey: "capture-source",
|
||||
method: "POST",
|
||||
path: "/sources",
|
||||
auth: "board-or-agent",
|
||||
capability: "api.routes.register",
|
||||
companyResolution: { from: "body", key: "companyId" }
|
||||
},
|
||||
{
|
||||
routeKey: "spaces",
|
||||
method: "GET",
|
||||
path: "/spaces",
|
||||
auth: "board-or-agent",
|
||||
capability: "api.routes.register",
|
||||
companyResolution: { from: "query", key: "companyId" }
|
||||
},
|
||||
{
|
||||
routeKey: "create-space",
|
||||
method: "POST",
|
||||
path: "/spaces",
|
||||
auth: "board",
|
||||
capability: "api.routes.register",
|
||||
companyResolution: { from: "body", key: "companyId" }
|
||||
},
|
||||
{
|
||||
routeKey: "update-space",
|
||||
method: "PATCH",
|
||||
path: "/spaces/:spaceSlug",
|
||||
auth: "board",
|
||||
capability: "api.routes.register",
|
||||
companyResolution: { from: "body", key: "companyId" }
|
||||
},
|
||||
{
|
||||
routeKey: "bootstrap-space",
|
||||
method: "POST",
|
||||
path: "/spaces/:spaceSlug/bootstrap",
|
||||
auth: "board",
|
||||
capability: "api.routes.register",
|
||||
companyResolution: { from: "body", key: "companyId" }
|
||||
},
|
||||
{
|
||||
routeKey: "archive-space",
|
||||
method: "POST",
|
||||
path: "/spaces/:spaceSlug/archive",
|
||||
auth: "board",
|
||||
capability: "api.routes.register",
|
||||
companyResolution: { from: "body", key: "companyId" }
|
||||
},
|
||||
{
|
||||
routeKey: "operations",
|
||||
method: "GET",
|
||||
path: "/operations",
|
||||
auth: "board-or-agent",
|
||||
capability: "api.routes.register",
|
||||
companyResolution: { from: "query", key: "companyId" }
|
||||
},
|
||||
{
|
||||
routeKey: "start-query",
|
||||
method: "POST",
|
||||
path: "/query-sessions",
|
||||
auth: "board",
|
||||
capability: "api.routes.register",
|
||||
companyResolution: { from: "body", key: "companyId" }
|
||||
},
|
||||
{
|
||||
routeKey: "file-as-page",
|
||||
method: "POST",
|
||||
path: "/file-as-page",
|
||||
auth: "board",
|
||||
capability: "api.routes.register",
|
||||
companyResolution: { from: "body", key: "companyId" }
|
||||
}
|
||||
],
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "sidebar",
|
||||
id: "wiki-sidebar",
|
||||
displayName: "Wiki",
|
||||
exportName: "SidebarLink",
|
||||
order: 35
|
||||
},
|
||||
{
|
||||
type: "page",
|
||||
id: "wiki-page",
|
||||
displayName: "Wiki",
|
||||
exportName: "WikiPage",
|
||||
routePath: "wiki"
|
||||
},
|
||||
{
|
||||
type: "routeSidebar",
|
||||
id: "wiki-route-sidebar",
|
||||
displayName: "Wiki",
|
||||
exportName: "WikiRouteSidebar",
|
||||
routePath: "wiki"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
|
||||
export const REQUIRED_WIKI_DIRECTORIES = [
|
||||
"raw",
|
||||
"wiki",
|
||||
"wiki/sources",
|
||||
"wiki/projects",
|
||||
"wiki/entities",
|
||||
"wiki/concepts",
|
||||
"wiki/synthesis",
|
||||
] as const;
|
||||
|
||||
export const REQUIRED_WIKI_FILES = ["AGENTS.md", "IDEA.md", "wiki/index.md", "wiki/log.md"] as const;
|
||||
export const KARPATHY_LLM_WIKI_GIST_URL = "https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f";
|
||||
|
||||
function templateFile(path: string): string {
|
||||
return readFileSync(new URL(`../templates/${path}`, import.meta.url), "utf8");
|
||||
}
|
||||
|
||||
function agentInstructionFiles(agentKey: string): Record<string, string> {
|
||||
const root = new URL(`../agents/${agentKey}/`, import.meta.url);
|
||||
const files: Record<string, string> = {};
|
||||
|
||||
function walk(relativeDir: string) {
|
||||
const dirUrl = new URL(relativeDir ? `${relativeDir}/` : "./", root);
|
||||
for (const entry of readdirSync(dirUrl)) {
|
||||
if (entry === ".DS_Store") continue;
|
||||
const relativePath = relativeDir ? `${relativeDir}/${entry}` : entry;
|
||||
const entryUrl = new URL(relativePath, root);
|
||||
const stat = statSync(entryUrl);
|
||||
if (stat.isDirectory()) {
|
||||
walk(relativePath);
|
||||
} else if (stat.isFile()) {
|
||||
files[relativePath] = readFileSync(entryUrl, "utf8");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk("");
|
||||
return Object.fromEntries(Object.entries(files).sort(([left], [right]) => left.localeCompare(right)));
|
||||
}
|
||||
|
||||
export const DEFAULT_WIKI_SCHEMA = templateFile("AGENTS.md");
|
||||
export const DEFAULT_AGENT_INSTRUCTION_FILES = agentInstructionFiles("wiki-maintainer");
|
||||
export const DEFAULT_AGENT_INSTRUCTIONS = DEFAULT_AGENT_INSTRUCTION_FILES["AGENTS.md"] ?? "";
|
||||
export const DEFAULT_IDEA = templateFile("IDEA.md");
|
||||
export const DEFAULT_INDEX = templateFile("wiki/index.md");
|
||||
export const DEFAULT_LOG = templateFile("wiki/log.md");
|
||||
export const DEFAULT_GITIGNORE = templateFile(".gitignore");
|
||||
|
||||
export const QUERY_PROMPT = `Answer from the LLM Wiki using the installed wiki-query skill.
|
||||
|
||||
Read the target space's wiki/index.md first, inspect relevant pages and raw/source references in that same space, cite the wiki page paths and raw source paths used, and say when the wiki does not contain enough evidence. Useful durable synthesis should be filed back into wiki/synthesis/ inside that same space. Always pass the operation issue's wikiId and spaceSlug to LLM Wiki tools.
|
||||
`;
|
||||
|
||||
export const LINT_PROMPT = `Lint the LLM Wiki using the installed wiki-lint skill.
|
||||
|
||||
Audit the target space only for contradictions, stale claims, orphan pages, missing backlinks, weak provenance, and wiki/index.md / wiki/log.md drift. Also look for important concepts mentioned without pages and answers that should have been filed back into wiki/. Return findings grouped by severity with concrete file paths, evidence, and suggested fixes — do not auto-apply edits. Always pass the operation issue's wikiId and spaceSlug to LLM Wiki tools.
|
||||
`;
|
||||
|
||||
export const BOOTSTRAP_FILES: ReadonlyArray<{ path: string; contents: string }> = [
|
||||
{ path: ".gitignore", contents: DEFAULT_GITIGNORE },
|
||||
{ path: "AGENTS.md", contents: DEFAULT_WIKI_SCHEMA },
|
||||
{ path: "IDEA.md", contents: DEFAULT_IDEA },
|
||||
{ path: "wiki/index.md", contents: DEFAULT_INDEX },
|
||||
{ path: "wiki/log.md", contents: DEFAULT_LOG },
|
||||
{ path: "raw/.gitkeep", contents: "" },
|
||||
{ path: "wiki/sources/.gitkeep", contents: "" },
|
||||
{ path: "wiki/projects/.gitkeep", contents: "" },
|
||||
{ path: "wiki/entities/.gitkeep", contents: "" },
|
||||
{ path: "wiki/concepts/.gitkeep", contents: "" },
|
||||
{ path: "wiki/synthesis/.gitkeep", contents: "" },
|
||||
];
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
export { SidebarLink, SettingsPage, WikiPage, WikiRouteSidebar } from "./app.js";
|
||||
@@ -0,0 +1,49 @@
|
||||
type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
|
||||
|
||||
export type IngestSourceActionResult = {
|
||||
operation?: {
|
||||
issue?: {
|
||||
id?: unknown;
|
||||
} | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export function readIngestOperationIssueId(result: unknown): string {
|
||||
const issueId = (result as IngestSourceActionResult | null)?.operation?.issue?.id;
|
||||
if (typeof issueId === "string" && issueId.trim()) return issueId;
|
||||
throw new Error("Ingest operation did not return an issue id; the dropped file could not be attached.");
|
||||
}
|
||||
|
||||
async function readUploadError(response: Response): Promise<string> {
|
||||
const body = await response.json().catch(() => null);
|
||||
if (body && typeof body === "object") {
|
||||
const error = (body as { error?: unknown; message?: unknown }).error;
|
||||
if (typeof error === "string" && error.trim()) return error;
|
||||
const message = (body as { error?: unknown; message?: unknown }).message;
|
||||
if (typeof message === "string" && message.trim()) return message;
|
||||
}
|
||||
return `Attachment upload failed with HTTP ${response.status}.`;
|
||||
}
|
||||
|
||||
export async function uploadIssueAttachmentFile(input: {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
file: File;
|
||||
fetchImpl?: FetchLike;
|
||||
}): Promise<unknown> {
|
||||
const fetchImpl = input.fetchImpl ?? fetch;
|
||||
const form = new FormData();
|
||||
form.append("file", input.file);
|
||||
const response = await fetchImpl(
|
||||
`/api/companies/${encodeURIComponent(input.companyId)}/issues/${encodeURIComponent(input.issueId)}/attachments`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: form,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(await readUploadError(response));
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./wiki/core.js";
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
.DS_Store
|
||||
.obsidian/workspace*
|
||||
.obsidian/cache
|
||||
@@ -0,0 +1,137 @@
|
||||
# AGENTS.md — LLM Wiki Schema
|
||||
|
||||
You are the maintainer of this personal wiki. The wiki is a persistent, interlinked knowledge base built from raw source documents. You read sources, extract knowledge, and integrate it into evolving wiki pages. The user curates sources, directs analysis, and asks questions; you handle the bookkeeping.
|
||||
|
||||
The underlying pattern is described in `IDEA.md` (Karpathy's "LLM Wiki" gist). Read it if you need the philosophy; this file is the operational schema.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
.
|
||||
├── AGENTS.md # this file — your operating instructions
|
||||
├── IDEA.md # the pattern this wiki follows
|
||||
├── raw/ # immutable source documents (you read, never write)
|
||||
└── wiki/ # generated, owned by you
|
||||
├── index.md # catalog of all pages
|
||||
├── log.md # append-only timeline of operations
|
||||
├── sources/ # one summary page per source
|
||||
├── projects/ # Paperclip project overviews, standups, decisions, and history
|
||||
│ └── <slug>/
|
||||
│ ├── index.md
|
||||
│ ├── standup.md
|
||||
│ ├── decisions.md
|
||||
│ └── history.md
|
||||
├── entities/ # people, organizations, products, places
|
||||
├── concepts/ # ideas, frameworks, definitions
|
||||
└── synthesis/ # cross-cutting analysis, comparisons, theses
|
||||
```
|
||||
|
||||
The subdirectories under `wiki/` are conventional, not enforced. Add new categories (e.g. `wiki/papers/`) as the domain demands — and update this file when you do.
|
||||
|
||||
Paperclip project material lives only under `wiki/projects/<project-slug>/`. Do not create a top-level `projects/` directory.
|
||||
|
||||
- `wiki/projects/<project-slug>/standup.md` is the executive-level project standup. It answers where the project stands today, what changed recently, current blockers/risks, and the next concrete actions.
|
||||
- `wiki/projects/<project-slug>/index.md` is the durable knowledge page. It explains what the project is, why it exists, decisions made, history, and long-lived context.
|
||||
- Keep the two linked. A standup should link to the durable project page, and the durable project page should point at the current standup for live status.
|
||||
- Update `standup.md` whenever Paperclip project, issue, plan, comment, blocker, approval, or status history materially changes the project's current state. Do not append endless dated sections; rewrite it as today's concise status.
|
||||
- Project writing should be editorial and concept-grouped. Do not dump issue queues, UUIDs, raw metadata, or date-heavy ledgers into project pages. Reference Paperclip tasks with human issue links where useful, but make headings and paragraphs explain the concepts, decisions, completed work, next work, and blockers in plain executive language.
|
||||
|
||||
## Page conventions
|
||||
|
||||
- **Filename:** kebab-case, `.md`. Treat filenames as stable; do not rename without updating backlinks.
|
||||
- **Frontmatter:** YAML at the top of every wiki page.
|
||||
```yaml
|
||||
---
|
||||
title: Human-readable title
|
||||
type: source | project | entity | concept | synthesis
|
||||
tags: [tag-a, tag-b]
|
||||
sources: [raw/doc.pdf] # for source pages and synthesis pages
|
||||
created: YYYY-MM-DD
|
||||
updated: YYYY-MM-DD
|
||||
---
|
||||
```
|
||||
- **Cross-links:** Obsidian-style `[[wiki/entities/some-page]]` (or `[[some-page]]` when unambiguous). When you mention a concept or entity that has — or should have — its own page, link it.
|
||||
- **Citations:** cite the source inline whenever a claim comes from one: `(see [[wiki/sources/some-slug]])`.
|
||||
- **Voice:** terse, factual, neutral. The wiki is reference material, not narrative.
|
||||
|
||||
## Operations
|
||||
|
||||
### Ingest
|
||||
|
||||
Triggered when the user drops a file in `raw/` and asks to process it (or just says "ingest").
|
||||
|
||||
1. Read the source end to end.
|
||||
2. Briefly discuss key takeaways with the user before writing — confirm what to emphasize.
|
||||
3. Create `wiki/sources/<slug>.md`: a summary page (~300–800 words) covering the source's main claims, structure, and notable quotes or data.
|
||||
4. Update or create relevant pages in `entities/`, `concepts/`, `synthesis/`. A typical ingest touches 5–15 pages.
|
||||
5. Add any new pages to `wiki/index.md`.
|
||||
6. Append a log entry:
|
||||
```
|
||||
## [YYYY-MM-DD] ingest | <source title>
|
||||
- source: raw/<filename>
|
||||
- new pages: [[...]], [[...]]
|
||||
- updated pages: [[...]], [[...]]
|
||||
- notes: <one-line synthesis, contradiction flagged, or open question>
|
||||
```
|
||||
|
||||
When new information contradicts an existing page, do **not** silently overwrite. Flag the contradiction on the page (a `> ⚠ contradicted by [[...]] (YYYY-MM-DD)` callout) and note it in the log.
|
||||
|
||||
### Project updates
|
||||
|
||||
Triggered when Paperclip project, issue, plan, comment, blocker, or status history is distilled into the wiki.
|
||||
|
||||
1. Create or update `wiki/projects/<project-slug>/standup.md` first. Every Paperclip project represented in the wiki must have one. Keep stable sections for executive readout, what changed, decisions, blockers/risks, next actions, and links.
|
||||
2. Create or update `wiki/projects/<project-slug>/index.md` as the durable project overview. Keep stable sections for overview, current direction, workstreams, decisions, open risks/blockers, and references.
|
||||
3. Use `wiki/projects/<project-slug>/decisions.md` for accepted/rejected plans, architectural decisions, approval outcomes, and reversals when a project has enough decision history to warrant a separate page.
|
||||
4. Use `wiki/projects/<project-slug>/history.md` for compact narrative history of meaningful project movement. Group by phase or concept; do not mirror every issue comment.
|
||||
5. Always cite Paperclip source material with readable links to issue identifiers, document keys, issue documents, approvals, and raw/source pages. Do not put UUIDs in prose unless the UUID itself is the subject.
|
||||
6. Update `wiki/index.md` under Projects and append a `project` log entry to `wiki/log.md`.
|
||||
|
||||
### Query
|
||||
|
||||
The user asks a question. You:
|
||||
|
||||
1. Read `wiki/index.md` to find candidate pages.
|
||||
2. Read those pages; follow links as needed.
|
||||
3. Answer with citations back to wiki pages, and ultimately to raw sources.
|
||||
4. If the answer is substantial (a comparison, analysis, new synthesis), offer to file it under `wiki/synthesis/` so the work compounds rather than disappearing into chat history.
|
||||
|
||||
If the wiki lacks what the question needs, say so plainly and suggest sources to ingest or web searches to run.
|
||||
|
||||
### Lint
|
||||
|
||||
On request ("lint", "health check"), scan for:
|
||||
|
||||
- contradictions across pages
|
||||
- claims a newer source has superseded
|
||||
- orphan pages (not linked from `index.md` or any other page)
|
||||
- concepts mentioned in multiple places but lacking a dedicated page
|
||||
- broken `[[wiki-links]]`
|
||||
- gaps where a web search or new source would help
|
||||
|
||||
Report findings as a checklist and ask the user which to act on.
|
||||
|
||||
## index.md format
|
||||
|
||||
A catalog organized by category. Each line: `- [[path]] — one-line summary`. Keep it scannable; this is your primary navigation aid before opening pages.
|
||||
|
||||
## log.md format
|
||||
|
||||
Append new entries to the bottom. Every entry header follows:
|
||||
|
||||
```
|
||||
## [YYYY-MM-DD] <op> | <subject>
|
||||
```
|
||||
|
||||
so `grep "^## \[" wiki/log.md | tail -10` always returns recent activity. Operations: `ingest`, `query`, `lint`, `setup`, `refactor`.
|
||||
|
||||
## Customization
|
||||
|
||||
This schema is intentionally generic. As the wiki's domain becomes clear, evolve it:
|
||||
|
||||
- add domain-specific page types and subdirectories
|
||||
- adjust frontmatter fields
|
||||
- specify preferred output formats for queries (Marp slides, charts, tables)
|
||||
- record workflow preferences (one-at-a-time vs batch ingest, level of human supervision)
|
||||
|
||||
When you and the user agree on a convention, **write it into this file**. The schema is the wiki's source of truth for how the wiki is built.
|
||||
@@ -0,0 +1,75 @@
|
||||
# LLM Wiki
|
||||
|
||||
A pattern for building personal knowledge bases using LLMs.
|
||||
|
||||
This is an idea file, it is designed to be copy pasted to your own LLM Agent (e.g. OpenAI Codex, Claude Code, OpenCode / Pi, or etc.). Its goal is to communicate the high level idea, but your agent will build out the specifics in collaboration with you.
|
||||
|
||||
## The core idea
|
||||
|
||||
Most people's experience with LLMs and documents looks like RAG: you upload a collection of files, the LLM retrieves relevant chunks at query time, and generates an answer. This works, but the LLM is rediscovering knowledge from scratch on every question. There's no accumulation. Ask a subtle question that requires synthesizing five documents, and the LLM has to find and piece together the relevant fragments every time. Nothing is built up. NotebookLM, ChatGPT file uploads, and most RAG systems work this way.
|
||||
|
||||
The idea here is different. Instead of just retrieving from raw documents at query time, the LLM **incrementally builds and maintains a persistent wiki** — a structured, interlinked collection of markdown files that sits between you and the raw sources. When you add a new source, the LLM doesn't just index it for later retrieval. It reads it, extracts the key information, and integrates it into the existing wiki — updating entity pages, revising topic summaries, noting where new data contradicts old claims, strengthening or challenging the evolving synthesis. The knowledge is compiled once and then *kept current*, not re-derived on every query.
|
||||
|
||||
This is the key difference: **the wiki is a persistent, compounding artifact.** The cross-references are already there. The contradictions have already been flagged. The synthesis already reflects everything you've read. The wiki keeps getting richer with every source you add and every question you ask.
|
||||
|
||||
You never (or rarely) write the wiki yourself — the LLM writes and maintains all of it. You're in charge of sourcing, exploration, and asking the right questions. The LLM does all the grunt work — the summarizing, cross-referencing, filing, and bookkeeping that makes a knowledge base actually useful over time. In practice, I have the LLM agent open on one side and Obsidian open on the other. The LLM makes edits based on our conversation, and I browse the results in real time — following links, checking the graph view, reading the updated pages. Obsidian is the IDE; the LLM is the programmer; the wiki is the codebase.
|
||||
|
||||
This can apply to a lot of different contexts. A few examples:
|
||||
|
||||
- **Personal**: tracking your own goals, health, psychology, self-improvement — filing journal entries, articles, podcast notes, and building up a structured picture of yourself over time.
|
||||
- **Research**: going deep on a topic over weeks or months — reading papers, articles, reports, and incrementally building a comprehensive wiki with an evolving thesis.
|
||||
- **Reading a book**: filing each chapter as you go, building out pages for characters, themes, plot threads, and how they connect. By the end you have a rich companion wiki. Think of fan wikis like [Tolkien Gateway](https://tolkiengateway.net/wiki/Main_Page) — thousands of interlinked pages covering characters, places, events, languages, built by a community of volunteers over years. You could build something like that personally as you read, with the LLM doing all the cross-referencing and maintenance.
|
||||
- **Business/team**: an internal wiki maintained by LLMs, fed by Slack threads, meeting transcripts, project documents, customer calls. Possibly with humans in the loop reviewing updates. The wiki stays current because the LLM does the maintenance that no one on the team wants to do.
|
||||
- **Competitive analysis, due diligence, trip planning, course notes, hobby deep-dives** — anything where you're accumulating knowledge over time and want it organized rather than scattered.
|
||||
|
||||
## Architecture
|
||||
|
||||
There are three layers:
|
||||
|
||||
**Raw sources** — your curated collection of source documents. Articles, papers, images, data files. These are immutable — the LLM reads from them but never modifies them. This is your source of truth.
|
||||
|
||||
**The wiki** — a directory of LLM-generated markdown files. Summaries, entity pages, concept pages, comparisons, an overview, a synthesis. The LLM owns this layer entirely. It creates pages, updates them when new sources arrive, maintains cross-references, and keeps everything consistent. You read it; the LLM writes it.
|
||||
|
||||
**The schema** — a document (e.g. AGENTS.md for Paperclip agents) that tells the LLM how the wiki is structured, what the conventions are, and what workflows to follow when ingesting sources, answering questions, or maintaining the wiki. This is the key configuration file — it's what makes the LLM a disciplined wiki maintainer rather than a generic chatbot. You and the LLM co-evolve this over time as you figure out what works for your domain.
|
||||
|
||||
## Operations
|
||||
|
||||
**Ingest.** You drop a new source into the raw collection and tell the LLM to process it. An example flow: the LLM reads the source, discusses key takeaways with you, writes a summary page in the wiki, updates the index, updates relevant entity and concept pages across the wiki, and appends an entry to the log. A single source might touch 10-15 wiki pages. Personally I prefer to ingest sources one at a time and stay involved — I read the summaries, check the updates, and guide the LLM on what to emphasize. But you could also batch-ingest many sources at once with less supervision. It's up to you to develop the workflow that fits your style and document it in the schema for future sessions.
|
||||
|
||||
**Query.** You ask questions against the wiki. The LLM searches for relevant pages, reads them, and synthesizes an answer with citations. Answers can take different forms depending on the question — a markdown page, a comparison table, a slide deck (Marp), a chart (matplotlib), a canvas. The important insight: **good answers can be filed back into the wiki as new pages.** A comparison you asked for, an analysis, a connection you discovered — these are valuable and shouldn't disappear into chat history. This way your explorations compound in the knowledge base just like ingested sources do.
|
||||
|
||||
**Lint.** Periodically, ask the LLM to health-check the wiki. Look for: contradictions between pages, stale claims that newer sources have superseded, orphan pages with no inbound links, important concepts mentioned but lacking their own page, missing cross-references, data gaps that could be filled with a web search. The LLM is good at suggesting new questions to investigate and new sources to look for. This keeps the wiki healthy as it grows.
|
||||
|
||||
## Indexing and logging
|
||||
|
||||
Two special files help the LLM (and you) navigate the wiki as it grows. They serve different purposes:
|
||||
|
||||
**index.md** is content-oriented. It's a catalog of everything in the wiki — each page listed with a link, a one-line summary, and optionally metadata like date or source count. Organized by category (entities, concepts, sources, etc.). The LLM updates it on every ingest. When answering a query, the LLM reads the index first to find relevant pages, then drills into them. This works surprisingly well at moderate scale (~100 sources, ~hundreds of pages) and avoids the need for embedding-based RAG infrastructure.
|
||||
|
||||
**log.md** is chronological. It's an append-only record of what happened and when — ingests, queries, lint passes. A useful tip: if each entry starts with a consistent prefix (e.g. `## [2026-04-02] ingest | Article Title`), the log becomes parseable with simple unix tools — `grep "^## \[" log.md | tail -5` gives you the last 5 entries. The log gives you a timeline of the wiki's evolution and helps the LLM understand what's been done recently.
|
||||
|
||||
## Optional: CLI tools
|
||||
|
||||
At some point you may want to build small tools that help the LLM operate on the wiki more efficiently. A search engine over the wiki pages is the most obvious one — at small scale the index file is enough, but as the wiki grows you want proper search. [qmd](https://github.com/tobi/qmd) is a good option: it's a local search engine for markdown files with hybrid BM25/vector search and LLM re-ranking, all on-device. It has both a CLI (so the LLM can shell out to it) and an MCP server (so the LLM can use it as a native tool). You could also build something simpler yourself — the LLM can help you vibe-code a naive search script as the need arises.
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- **Obsidian Web Clipper** is a browser extension that converts web articles to markdown. Very useful for quickly getting sources into your raw collection.
|
||||
- **Download images locally.** In Obsidian Settings → Files and links, set "Attachment folder path" to a fixed directory (e.g. `raw/assets/`). Then in Settings → Hotkeys, search for "Download" to find "Download attachments for current file" and bind it to a hotkey (e.g. Ctrl+Shift+D). After clipping an article, hit the hotkey and all images get downloaded to local disk. This is optional but useful — it lets the LLM view and reference images directly instead of relying on URLs that may break. Note that LLMs can't natively read markdown with inline images in one pass — the workaround is to have the LLM read the text first, then view some or all of the referenced images separately to gain additional context. It's a bit clunky but works well enough.
|
||||
- **Obsidian's graph view** is the best way to see the shape of your wiki — what's connected to what, which pages are hubs, which are orphans.
|
||||
- **Marp** is a markdown-based slide deck format. Obsidian has a plugin for it. Useful for generating presentations directly from wiki content.
|
||||
- **Dataview** is an Obsidian plugin that runs queries over page frontmatter. If your LLM adds YAML frontmatter to wiki pages (tags, dates, source counts), Dataview can generate dynamic tables and lists.
|
||||
- The wiki is just a git repo of markdown files. You get version history, branching, and collaboration for free.
|
||||
|
||||
## Why this works
|
||||
|
||||
The tedious part of maintaining a knowledge base is not the reading or the thinking — it's the bookkeeping. Updating cross-references, keeping summaries current, noting when new data contradicts old claims, maintaining consistency across dozens of pages. Humans abandon wikis because the maintenance burden grows faster than the value. LLMs don't get bored, don't forget to update a cross-reference, and can touch 15 files in one pass. The wiki stays maintained because the cost of maintenance is near zero.
|
||||
|
||||
The human's job is to curate sources, direct the analysis, ask good questions, and think about what it all means. The LLM's job is everything else.
|
||||
|
||||
The idea is related in spirit to Vannevar Bush's Memex (1945) — a personal, curated knowledge store with associative trails between documents. Bush's vision was closer to this than to what the web became: private, actively curated, with the connections between documents as valuable as the documents themselves. The part he couldn't solve was who does the maintenance. The LLM handles that.
|
||||
|
||||
|
||||
## Note
|
||||
|
||||
This document is intentionally abstract. It describes the idea, not a specific implementation. The exact directory structure, the schema conventions, the page formats, the tooling — all of that will depend on your domain, your preferences, and your LLM of choice. Everything mentioned above is optional and modular — pick what's useful, ignore what isn't. For example: your sources might be text-only, so you don't need image handling at all. Your wiki might be small enough that the index file is all you need, no search engine required. You might not care about slide decks and just want markdown pages. You might want a completely different set of output formats. The right way to use this is to share it with your LLM agent and work together to instantiate a version that fits your needs. The document's only job is to communicate the pattern. Your LLM can figure out the rest.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Index
|
||||
|
||||
Catalog of durable wiki pages and linked project standups. Updated on every ingest or Paperclip distill.
|
||||
|
||||
## Sources
|
||||
|
||||
_(none yet)_
|
||||
|
||||
## Projects
|
||||
|
||||
_(none yet)_
|
||||
|
||||
## Entities
|
||||
|
||||
_(none yet)_
|
||||
|
||||
## Concepts
|
||||
|
||||
_(none yet)_
|
||||
|
||||
## Synthesis
|
||||
|
||||
_(none yet)_
|
||||
@@ -0,0 +1,10 @@
|
||||
# Log
|
||||
|
||||
Append-only chronological record of wiki operations.
|
||||
|
||||
## [2026-05-03] setup | wiki initialized
|
||||
- created `AGENTS.md` (schema)
|
||||
- created `raw/` for source documents
|
||||
- created `wiki/` skeleton: `index.md`, `log.md`, `sources/`, `projects/`, `entities/`, `concepts/`, `synthesis/`
|
||||
- created `wiki/projects/` for Paperclip project overviews and standups
|
||||
- pattern reference: `IDEA.md` (Karpathy "LLM Wiki" gist, https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f)
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readIngestOperationIssueId, uploadIssueAttachmentFile } from "../src/ui/issue-attachments.js";
|
||||
|
||||
describe("LLM Wiki issue attachment uploads", () => {
|
||||
it("reads the ingest operation issue id from the action result", () => {
|
||||
expect(readIngestOperationIssueId({
|
||||
operation: {
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
},
|
||||
},
|
||||
})).toBe("issue-1");
|
||||
});
|
||||
|
||||
it("rejects an ingest result that cannot identify the created issue", () => {
|
||||
expect(() => readIngestOperationIssueId({ operation: { issue: null } }))
|
||||
.toThrow("did not return an issue id");
|
||||
});
|
||||
|
||||
it("uploads the original file to the created ingest task", async () => {
|
||||
const file = new File(["hello"], "source notes.md", { type: "text/markdown" });
|
||||
const calls: Array<{ input: string; init: RequestInit }> = [];
|
||||
const fetchImpl = async (input: string, init: RequestInit) => {
|
||||
calls.push({ input, init });
|
||||
return new Response(JSON.stringify({ id: "attachment-1" }), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
await expect(uploadIssueAttachmentFile({
|
||||
companyId: "company 1",
|
||||
issueId: "issue/1",
|
||||
file,
|
||||
fetchImpl,
|
||||
})).resolves.toEqual({ id: "attachment-1" });
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.input).toBe("/api/companies/company%201/issues/issue%2F1/attachments");
|
||||
expect(calls[0]?.init.method).toBe("POST");
|
||||
expect(calls[0]?.init.credentials).toBe("include");
|
||||
const body = calls[0]?.init.body;
|
||||
expect(body).toBeInstanceOf(FormData);
|
||||
expect((body as FormData).get("file")).toBe(file);
|
||||
});
|
||||
|
||||
it("surfaces server upload errors", async () => {
|
||||
const fetchImpl = async () => new Response(JSON.stringify({ error: "Attachment exceeds 10 bytes" }), {
|
||||
status: 422,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
await expect(uploadIssueAttachmentFile({
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
file: new File(["hello"], "source.txt"),
|
||||
fetchImpl,
|
||||
})).rejects.toThrow("Attachment exceeds 10 bytes");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env node
|
||||
import { mkdirSync, copyFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, resolve, extname } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import http from "node:http";
|
||||
import esbuild from "esbuild";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const pkgRoot = resolve(__dirname, "..", "..");
|
||||
const outDir = resolve(pkgRoot, "dist", "screenshots");
|
||||
const screensDir = resolve(pkgRoot, "screenshots");
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
mkdirSync(screensDir, { recursive: true });
|
||||
|
||||
const entry = resolve(__dirname, "entry.tsx");
|
||||
|
||||
const repoRoot = resolve(pkgRoot, "..", "..", "..");
|
||||
const reactPath = resolve(repoRoot, "node_modules/.pnpm/react@19.2.4/node_modules/react");
|
||||
const reactDomPath = resolve(repoRoot, "node_modules/.pnpm/react-dom@19.2.4_react@19.2.4/node_modules/react-dom");
|
||||
|
||||
console.log("Bundling screenshot harness…");
|
||||
await esbuild.build({
|
||||
entryPoints: [entry],
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "browser",
|
||||
target: "es2022",
|
||||
outfile: resolve(outDir, "bundle.js"),
|
||||
jsx: "automatic",
|
||||
loader: { ".tsx": "tsx", ".ts": "ts" },
|
||||
define: { "process.env.NODE_ENV": '"production"' },
|
||||
logLevel: "warning",
|
||||
alias: {
|
||||
"react": reactPath,
|
||||
"react-dom": reactDomPath,
|
||||
"react-dom/client": resolve(reactDomPath, "client.js"),
|
||||
"react/jsx-runtime": resolve(reactPath, "jsx-runtime.js"),
|
||||
},
|
||||
});
|
||||
|
||||
copyFileSync(resolve(__dirname, "index.html"), resolve(outDir, "index.html"));
|
||||
|
||||
const desktopViewport = { width: 1440, height: 920 };
|
||||
const mobileViewport = { width: 390, height: 844 };
|
||||
|
||||
const desktopTargets = [
|
||||
{ slug: "01-wiki-browse", view: "wiki-sidebar", section: null },
|
||||
{ slug: "02-wiki-ingest", view: "wiki-sidebar", section: "ingest" },
|
||||
{ slug: "03-wiki-query", view: "wiki-sidebar", section: "query" },
|
||||
{ slug: "04-wiki-lint", view: "wiki-sidebar", section: "lint" },
|
||||
{ slug: "05-wiki-history", view: "wiki-sidebar", section: "history" },
|
||||
{ slug: "06-wiki-settings", view: "wiki-sidebar", section: "settings" },
|
||||
{ slug: "07-host-settings", view: "settings" },
|
||||
{ slug: "09-sidebar-link", view: "sidebar" },
|
||||
{ slug: "11-wiki-distillation-settings", view: "wiki-sidebar", section: "settings/distillation" },
|
||||
{ slug: "12-wiki-distillation-unconfigured", view: "wiki-sidebar", section: "settings/distillation", search: "unconfigured=1" },
|
||||
{ slug: "20-spaces-sidebar", view: "wiki-sidebar", section: null },
|
||||
{ slug: "21-spaces-ingest", view: "wiki-sidebar", section: "ingest" },
|
||||
{ slug: "21a-spaces-ingest-with-disclaimer", view: "wiki-sidebar", section: "ingest" },
|
||||
{ slug: "22-spaces-edit", view: "wiki-sidebar", section: "settings/spaces/team-research", scrollToText: "Paperclip ingestion" },
|
||||
{ slug: "22a-spaces-edit-default", view: "wiki-sidebar", section: "settings/spaces/default", scrollToText: "Paperclip ingestion" },
|
||||
{ slug: "23-spaces-non-default-route", view: "wiki-sidebar", section: "spaces/team-research" },
|
||||
{ slug: "24-spaces-create-modal", view: "wiki-sidebar", section: null, openCreateSpaceModal: true },
|
||||
];
|
||||
|
||||
const mobileTargets = desktopTargets
|
||||
.filter((target) => !target.openCreateSpaceModal)
|
||||
.map((target) => ({
|
||||
...target,
|
||||
slug: `mobile/${target.slug}`,
|
||||
// In the production host, the route sidebar lives in the mobile drawer.
|
||||
// The page body should therefore be checked without the desktop sidebar.
|
||||
view: target.view === "wiki-sidebar" ? "wiki" : target.view,
|
||||
viewport: mobileViewport,
|
||||
}));
|
||||
|
||||
const targets = [
|
||||
...desktopTargets.map((target) => ({ ...target, viewport: desktopViewport })),
|
||||
...mobileTargets,
|
||||
];
|
||||
|
||||
const playwrightUrl = pathToFileURL(resolve(pkgRoot, "node_modules/playwright/index.mjs")).href;
|
||||
const playwrightFallback = resolve(pkgRoot, "..", "..", "..", "node_modules", ".pnpm", "playwright@1.58.2", "node_modules", "playwright", "index.mjs");
|
||||
let playwrightModuleHref = playwrightUrl;
|
||||
if (!existsSync(resolve(pkgRoot, "node_modules/playwright/index.mjs"))) {
|
||||
if (existsSync(playwrightFallback)) {
|
||||
playwrightModuleHref = pathToFileURL(playwrightFallback).href;
|
||||
} else {
|
||||
throw new Error("Cannot locate playwright module");
|
||||
}
|
||||
}
|
||||
const { chromium } = await import(playwrightModuleHref);
|
||||
|
||||
const mimeFor = (ext) => ({
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".mjs": "application/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".map": "application/json; charset=utf-8",
|
||||
})[ext] ?? "application/octet-stream";
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const requestedPath = url.pathname === "/" ? "/index.html" : url.pathname;
|
||||
const ext = extname(requestedPath);
|
||||
const candidate = ext ? resolve(outDir, "." + requestedPath) : resolve(outDir, "./index.html");
|
||||
try {
|
||||
const body = readFileSync(candidate);
|
||||
res.writeHead(200, { "Content-Type": mimeFor(extname(candidate)) });
|
||||
res.end(body);
|
||||
} catch {
|
||||
res.writeHead(404);
|
||||
res.end("Not found: " + candidate);
|
||||
}
|
||||
});
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
const { port } = server.address();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: desktopViewport, deviceScaleFactor: 2 });
|
||||
const page = await context.newPage();
|
||||
page.on("console", (msg) => console.log(` [console.${msg.type()}]`, msg.text()));
|
||||
page.on("pageerror", (err) => console.error(" [pageerror]", err.message));
|
||||
|
||||
for (const target of targets) {
|
||||
const sectionPath = target.section ? `/${target.section}` : "";
|
||||
const search = target.search ? `?${target.search}` : "";
|
||||
const url = `${baseUrl}/PAP/wiki${sectionPath}${search}#${target.view}`;
|
||||
console.log(`→ rendering ${target.slug} (${url})`);
|
||||
await page.setViewportSize(target.viewport);
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(200);
|
||||
if (target.slug === "09-sidebar-link") {
|
||||
await page.addStyleTag({ content: "body { background: var(--sidebar); }" });
|
||||
}
|
||||
if (target.openCreateSpaceModal) {
|
||||
await page.evaluate(() => {
|
||||
const btn = document.querySelector('button[aria-label="Create space"]');
|
||||
if (!btn) throw new Error("Create space button not found in DOM");
|
||||
(btn).click();
|
||||
});
|
||||
await page.waitForSelector('[aria-labelledby="create-space-modal-title"]', { timeout: 5000 });
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
if (target.scrollToText) {
|
||||
await page.getByText(target.scrollToText).first().scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
const outFile = resolve(screensDir, `${target.slug}.png`);
|
||||
mkdirSync(dirname(outFile), { recursive: true });
|
||||
await page.screenshot({ path: outFile, fullPage: false });
|
||||
const horizontalOverflow = await page.evaluate(() => {
|
||||
const rootOverflow = document.documentElement.scrollWidth - document.documentElement.clientWidth;
|
||||
const bodyOverflow = document.body.scrollWidth - window.innerWidth;
|
||||
return Math.max(rootOverflow, bodyOverflow);
|
||||
});
|
||||
if (horizontalOverflow > 1) {
|
||||
throw new Error(`${target.slug} has ${horizontalOverflow}px horizontal overflow at ${target.viewport.width}px`);
|
||||
}
|
||||
console.log(` saved ${outFile}`);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
server.close();
|
||||
console.log("Done. Screenshots in", screensDir);
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./harness.js";
|
||||
|
||||
const container = document.getElementById("root");
|
||||
if (!container) throw new Error("No #root in harness host");
|
||||
createRoot(container).render(<App />);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>LLM Wiki UI Harness</title>
|
||||
<style>
|
||||
:root {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--primary: oklch(0.985 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.269 0 0);
|
||||
--input: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.145 0 0);
|
||||
}
|
||||
html, body, #root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Minimal Tailwind utility shim — supports just the classes used by the */
|
||||
/* plugin's WikiRouteSidebar so the screenshot harness renders something */
|
||||
/* visually close to the production host (which compiles real Tailwind). */
|
||||
.w-60 { width: 15rem; }
|
||||
.h-full { height: 100%; }
|
||||
.min-h-0 { min-height: 0; }
|
||||
.border-r { border-right-width: 1px; border-right-style: solid; }
|
||||
.border-t { border-top-width: 1px; border-top-style: solid; }
|
||||
.border-border { border-color: var(--border); }
|
||||
.bg-background { background: var(--background); }
|
||||
.bg-accent { background: var(--accent); }
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.flex-1 { flex: 1; }
|
||||
.items-center { align-items: center; }
|
||||
.gap-0\.5 { gap: 0.125rem; }
|
||||
.gap-1 { gap: 0.25rem; }
|
||||
.gap-1\.5 { gap: 0.375rem; }
|
||||
.gap-2 { gap: 0.5rem; }
|
||||
.gap-2\.5 { gap: 0.625rem; }
|
||||
.mb-2 { margin-bottom: 0.5rem; }
|
||||
.mb-3 { margin-bottom: 0.75rem; }
|
||||
.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
|
||||
.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
|
||||
.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
|
||||
.py-1\.5 { padding-top: 0.375rem; padding-bottom: 0.375rem; }
|
||||
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
|
||||
.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
|
||||
.shrink-0 { flex-shrink: 0; }
|
||||
.text-xs { font-size: 0.75rem; line-height: 1rem; }
|
||||
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
|
||||
.text-\[11px\] { font-size: 11px; }
|
||||
.text-\[13px\] { font-size: 13px; }
|
||||
.font-medium { font-weight: 500; }
|
||||
.font-semibold { font-weight: 600; }
|
||||
.font-bold { font-weight: 700; }
|
||||
.uppercase { text-transform: uppercase; }
|
||||
.tracking-normal { letter-spacing: 0; }
|
||||
.text-foreground { color: var(--foreground); }
|
||||
.text-foreground\/80 { color: color-mix(in oklab, var(--foreground) 80%, transparent); }
|
||||
.text-muted-foreground { color: var(--muted-foreground); }
|
||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rounded-md { border-radius: 0.375rem; }
|
||||
.transition-colors { transition: background-color 150ms, color 150ms; }
|
||||
.overflow-y-auto { overflow-y: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,781 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { createElement } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { WikiPage, WikiRouteSidebar } from "../src/ui/index.js";
|
||||
|
||||
const COMPANY_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const EXPANDED_STORAGE_KEY = `paperclipai.plugin-llm-wiki:route-sidebar-expanded:v2:${COMPANY_ID}`;
|
||||
|
||||
type BridgeGlobal = typeof globalThis & {
|
||||
__paperclipPluginBridge__?: {
|
||||
sdkUi?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
type FileTreeNodeLike = {
|
||||
name: string;
|
||||
path: string;
|
||||
kind: string;
|
||||
children?: FileTreeNodeLike[];
|
||||
};
|
||||
|
||||
type FileTreePropsLike = {
|
||||
nodes: FileTreeNodeLike[];
|
||||
selectedFile?: string | null;
|
||||
expandedPaths?: ReadonlySet<string> | readonly string[];
|
||||
onToggleDir?: (path: string) => void;
|
||||
onSelectFile?: (path: string) => void;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function createFileDragEvent(
|
||||
type: string,
|
||||
options: { files?: File[]; relatedTarget?: EventTarget | null } = {},
|
||||
) {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "dataTransfer", {
|
||||
value: {
|
||||
types: ["Files"],
|
||||
files: options.files ?? [],
|
||||
dropEffect: "none",
|
||||
},
|
||||
});
|
||||
Object.defineProperty(event, "relatedTarget", {
|
||||
value: options.relatedTarget ?? null,
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
function toArray(paths: FileTreePropsLike["expandedPaths"]): string[] {
|
||||
if (!paths) return [];
|
||||
return Array.isArray(paths) ? [...paths] : Array.from(paths);
|
||||
}
|
||||
|
||||
function renderTreeButtons(
|
||||
nodes: FileTreeNodeLike[],
|
||||
options: Pick<FileTreePropsLike, "onSelectFile" | "onToggleDir">,
|
||||
): ReturnType<typeof createElement>[] {
|
||||
const buttons: ReturnType<typeof createElement>[] = [];
|
||||
for (const node of nodes) {
|
||||
if (node.kind === "dir") {
|
||||
buttons.push(
|
||||
createElement("button", {
|
||||
key: node.path,
|
||||
type: "button",
|
||||
"data-toggle-dir": node.path,
|
||||
onClick: () => options.onToggleDir?.(node.path),
|
||||
}, node.name),
|
||||
);
|
||||
} else {
|
||||
buttons.push(
|
||||
createElement("button", {
|
||||
key: node.path,
|
||||
type: "button",
|
||||
"data-select-file": node.path,
|
||||
onClick: () => options.onSelectFile?.(node.path),
|
||||
}, node.name),
|
||||
);
|
||||
}
|
||||
buttons.push(...renderTreeButtons(node.children ?? [], options));
|
||||
}
|
||||
return buttons;
|
||||
}
|
||||
|
||||
describe("WikiRouteSidebar", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let hostLocation: { pathname: string; search: string; hash: string; state?: unknown };
|
||||
let navigatedTo: { to: string; options?: unknown } | null;
|
||||
let pluginDataCalls: Array<{ key: string; params?: Record<string, unknown> }>;
|
||||
let pluginActionCalls: Array<{ key: string; params?: unknown }>;
|
||||
let spacesRefreshCount: number;
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
hostLocation = {
|
||||
pathname: "/PAP/wiki/page/wiki/concepts/sidebar-navigation.md",
|
||||
search: "",
|
||||
hash: "",
|
||||
};
|
||||
navigatedTo = null;
|
||||
pluginDataCalls = [];
|
||||
pluginActionCalls = [];
|
||||
spacesRefreshCount = 0;
|
||||
(globalThis as BridgeGlobal).__paperclipPluginBridge__ = {
|
||||
sdkUi: {
|
||||
usePluginData: (key: string, params?: Record<string, unknown>) => {
|
||||
pluginDataCalls.push({ key, params });
|
||||
if (key === "spaces") {
|
||||
return {
|
||||
data: {
|
||||
spaces: [
|
||||
{
|
||||
id: "space-default",
|
||||
companyId: COMPANY_ID,
|
||||
wikiId: "default",
|
||||
slug: "default",
|
||||
displayName: "default",
|
||||
spaceType: "managed",
|
||||
folderMode: "managed_subfolder",
|
||||
rootFolderKey: "wiki-root",
|
||||
pathPrefix: null,
|
||||
configuredRootPath: null,
|
||||
accessScope: "shared",
|
||||
ownerUserId: null,
|
||||
ownerAgentId: null,
|
||||
teamKey: null,
|
||||
settings: {},
|
||||
status: "active",
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
{
|
||||
id: "space-engineering",
|
||||
companyId: COMPANY_ID,
|
||||
wikiId: "default",
|
||||
slug: "engineering",
|
||||
displayName: "Engineering",
|
||||
spaceType: "managed",
|
||||
folderMode: "managed_subfolder",
|
||||
rootFolderKey: "wiki-root",
|
||||
pathPrefix: "spaces/engineering",
|
||||
configuredRootPath: null,
|
||||
accessScope: "shared",
|
||||
ownerUserId: null,
|
||||
ownerAgentId: null,
|
||||
teamKey: null,
|
||||
settings: {},
|
||||
status: "active",
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
{
|
||||
id: "space-archived",
|
||||
companyId: COMPANY_ID,
|
||||
wikiId: "default",
|
||||
slug: "qa-team-lock",
|
||||
displayName: "QA Team Lock",
|
||||
spaceType: "managed",
|
||||
folderMode: "managed_subfolder",
|
||||
rootFolderKey: "wiki-root",
|
||||
pathPrefix: "spaces/qa-team-lock",
|
||||
configuredRootPath: null,
|
||||
accessScope: "shared",
|
||||
ownerUserId: null,
|
||||
ownerAgentId: null,
|
||||
teamKey: null,
|
||||
settings: {},
|
||||
status: "archived",
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: () => {
|
||||
spacesRefreshCount += 1;
|
||||
},
|
||||
};
|
||||
}
|
||||
if (key !== "pages") return { data: null, loading: false, error: null, refresh: () => undefined };
|
||||
return {
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
path: "wiki/concepts/sidebar-navigation.md",
|
||||
title: "Sidebar navigation",
|
||||
pageType: "concepts",
|
||||
backlinkCount: 0,
|
||||
sourceCount: 0,
|
||||
contentHash: "abc123",
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
sources: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: () => undefined,
|
||||
};
|
||||
},
|
||||
usePluginAction: (key: string) => async (params?: unknown) => {
|
||||
pluginActionCalls.push({ key, params });
|
||||
return {};
|
||||
},
|
||||
usePluginToast: () => () => undefined,
|
||||
useHostLocation: () => hostLocation,
|
||||
useHostNavigation: () => ({
|
||||
resolveHref: (to: string) => `/PAP${to.startsWith("/") ? to : `/${to}`}`,
|
||||
navigate: (to: string, options?: unknown) => {
|
||||
navigatedTo = { to, options };
|
||||
},
|
||||
linkProps: (to: string) => ({
|
||||
href: `/PAP${to.startsWith("/") ? to : `/${to}`}`,
|
||||
onClick: () => undefined,
|
||||
}),
|
||||
}),
|
||||
FileTree: (props: FileTreePropsLike) => createElement(
|
||||
"div",
|
||||
{
|
||||
role: "tree",
|
||||
"data-selected-file": props.selectedFile ?? "",
|
||||
"data-expanded-paths": toArray(props.expandedPaths).sort().join("|"),
|
||||
},
|
||||
renderTreeButtons(props.nodes, props),
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
window.localStorage.clear();
|
||||
delete (globalThis as BridgeGlobal).__paperclipPluginBridge__;
|
||||
});
|
||||
|
||||
it("defaults wiki categories open so local files are visible", () => {
|
||||
act(() => {
|
||||
root.render(createElement(WikiRouteSidebar, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
const tree = container.querySelector("[role='tree']") as HTMLElement;
|
||||
expect(tree.dataset.expandedPaths?.split("|")).toEqual([
|
||||
"wiki",
|
||||
"wiki/concepts",
|
||||
"wiki/entities",
|
||||
"wiki/projects",
|
||||
"wiki/sources",
|
||||
"wiki/synthesis",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders Ask before Add Content in the primary sidebar tools", () => {
|
||||
act(() => {
|
||||
root.render(createElement(WikiRouteSidebar, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
const primaryNavText = container.querySelector("nav[aria-label='Wiki primary']")?.textContent ?? "";
|
||||
expect(primaryNavText.indexOf("Ask")).toBeLessThan(primaryNavText.indexOf("Add Content"));
|
||||
});
|
||||
|
||||
it("collapses and expands the active space tree from the space row", () => {
|
||||
act(() => {
|
||||
root.render(createElement(WikiRouteSidebar, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
expect(container.querySelector("[role='tree']")).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
(container.querySelector("[aria-label='Collapse default space']") as HTMLElement).click();
|
||||
});
|
||||
|
||||
expect(container.querySelector("[role='tree']")).toBeNull();
|
||||
|
||||
act(() => {
|
||||
(container.querySelector("[aria-label='Expand default space']") as HTMLElement).click();
|
||||
});
|
||||
|
||||
expect(container.querySelector("[role='tree']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("omits redundant shared badges beside space names", () => {
|
||||
act(() => {
|
||||
root.render(createElement(WikiRouteSidebar, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("shared");
|
||||
});
|
||||
|
||||
it("hides archived spaces from the sidebar", () => {
|
||||
act(() => {
|
||||
root.render(createElement(WikiRouteSidebar, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Engineering");
|
||||
expect(container.textContent).not.toContain("QA Team Lock");
|
||||
expect(pluginDataCalls).not.toContainEqual({
|
||||
key: "pages",
|
||||
params: { companyId: COMPANY_ID, includeRaw: true, spaceSlug: "qa-team-lock" },
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes and leaves an archived active space after sidebar archive", async () => {
|
||||
hostLocation = {
|
||||
pathname: "/PAP/wiki/spaces/engineering",
|
||||
search: "",
|
||||
hash: "",
|
||||
};
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
act(() => {
|
||||
root.render(createElement(WikiRouteSidebar, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
(container.querySelector("[aria-label='Engineering space menu']") as HTMLButtonElement).click();
|
||||
});
|
||||
const archiveButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Archive space"));
|
||||
|
||||
await act(async () => {
|
||||
archiveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(pluginActionCalls).toContainEqual({
|
||||
key: "archive-space",
|
||||
params: { companyId: COMPANY_ID, spaceSlug: "engineering" },
|
||||
});
|
||||
expect(spacesRefreshCount).toBe(1);
|
||||
expect(navigatedTo).toEqual({ to: "/wiki", options: undefined });
|
||||
confirm.mockRestore();
|
||||
});
|
||||
|
||||
it("persists folder expansion client-side", () => {
|
||||
act(() => {
|
||||
root.render(createElement(WikiRouteSidebar, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
(container.querySelector("[data-toggle-dir='raw']") as HTMLButtonElement).click();
|
||||
});
|
||||
|
||||
// Toggled paths are stored under the active space slug ("default::") so
|
||||
// each space remembers its own expansion state. Legacy entries written
|
||||
// before the spaces refactor stay un-prefixed and still resolve to default.
|
||||
expect(JSON.parse(window.localStorage.getItem(EXPANDED_STORAGE_KEY) ?? "[]")).toEqual([
|
||||
"default::raw",
|
||||
"wiki",
|
||||
"wiki/concepts",
|
||||
"wiki/entities",
|
||||
"wiki/projects",
|
||||
"wiki/sources",
|
||||
"wiki/synthesis",
|
||||
]);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(createElement(WikiRouteSidebar, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
const tree = container.querySelector("[role='tree']") as HTMLElement;
|
||||
expect(tree.dataset.expandedPaths).toBe("raw|wiki|wiki/concepts|wiki/entities|wiki/projects|wiki/sources|wiki/synthesis");
|
||||
});
|
||||
|
||||
it("does not select a wiki-link destination from the route", () => {
|
||||
act(() => {
|
||||
root.render(createElement(WikiRouteSidebar, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
const tree = () => container.querySelector("[role='tree']") as HTMLElement;
|
||||
expect(tree().dataset.selectedFile).toBe("");
|
||||
});
|
||||
|
||||
it("keeps sidebar tree selection scoped to sidebar navigation", () => {
|
||||
act(() => {
|
||||
root.render(createElement(WikiRouteSidebar, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
const tree = () => container.querySelector("[role='tree']") as HTMLElement;
|
||||
|
||||
act(() => {
|
||||
(container.querySelector("[data-select-file='wiki/concepts/sidebar-navigation.md']") as HTMLButtonElement).click();
|
||||
});
|
||||
|
||||
expect(navigatedTo).toEqual({
|
||||
to: "/wiki/page/wiki/concepts/sidebar-navigation.md",
|
||||
options: { state: { paperclipWikiSidebarTreePath: "wiki/concepts/sidebar-navigation.md" } },
|
||||
});
|
||||
// The default space stays the active space, so its tree is rendered in the
|
||||
// sidebar; non-default spaces only render their tree once activated.
|
||||
expect(tree().dataset.selectedFile).toBe("wiki/concepts/sidebar-navigation.md");
|
||||
|
||||
hostLocation = {
|
||||
pathname: "/PAP/wiki/page/wiki/entities/paperclip.md",
|
||||
search: "",
|
||||
hash: "",
|
||||
};
|
||||
act(() => {
|
||||
root.render(createElement(WikiRouteSidebar, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
expect(tree().dataset.selectedFile).toBe("wiki/concepts/sidebar-navigation.md");
|
||||
|
||||
act(() => {
|
||||
(container.querySelector("[data-toggle-dir='wiki/concepts']") as HTMLButtonElement).click();
|
||||
});
|
||||
|
||||
expect(tree().dataset.selectedFile).toBe("wiki/concepts/sidebar-navigation.md");
|
||||
expect(tree().dataset.expandedPaths?.split("|")).not.toContain("wiki/concepts");
|
||||
});
|
||||
|
||||
it("warms inactive space pages so sidebar space switches have data ready", () => {
|
||||
act(() => {
|
||||
root.render(createElement(WikiRouteSidebar, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
expect(pluginDataCalls).toContainEqual({
|
||||
key: "pages",
|
||||
params: { companyId: COMPANY_ID, includeRaw: true, spaceSlug: "engineering" },
|
||||
});
|
||||
expect(pluginDataCalls).toContainEqual({
|
||||
key: "page-content",
|
||||
params: { companyId: COMPANY_ID, path: "wiki/concepts/sidebar-navigation.md", spaceSlug: "engineering" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("WikiPage", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let consoleError: ReturnType<typeof vi.spyOn>;
|
||||
let hostLocation: { pathname: string; search: string; hash: string };
|
||||
let navigatedTo: string | null;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
hostLocation = {
|
||||
pathname: "/PAP/wiki/page/wiki/projects/control-plane/index.md",
|
||||
search: "",
|
||||
hash: "",
|
||||
};
|
||||
navigatedTo = null;
|
||||
(globalThis as BridgeGlobal).__paperclipPluginBridge__ = {
|
||||
sdkUi: {
|
||||
usePluginData: (key: string) => {
|
||||
if (key === "overview") {
|
||||
return {
|
||||
data: { folder: { healthy: true }, wikiId: "default" },
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: () => undefined,
|
||||
};
|
||||
}
|
||||
if (key === "spaces") {
|
||||
return {
|
||||
data: {
|
||||
spaces: [
|
||||
{
|
||||
id: "space-default",
|
||||
companyId: COMPANY_ID,
|
||||
wikiId: "default",
|
||||
slug: "default",
|
||||
displayName: "default",
|
||||
spaceType: "managed",
|
||||
folderMode: "managed_subfolder",
|
||||
rootFolderKey: "wiki-root",
|
||||
pathPrefix: null,
|
||||
configuredRootPath: null,
|
||||
accessScope: "shared",
|
||||
ownerUserId: null,
|
||||
ownerAgentId: null,
|
||||
teamKey: null,
|
||||
settings: {},
|
||||
status: "active",
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: () => undefined,
|
||||
};
|
||||
}
|
||||
if (key === "settings") {
|
||||
return {
|
||||
data: {
|
||||
folder: {
|
||||
configured: true,
|
||||
path: "/tmp/company-wiki",
|
||||
realPath: "/tmp/company-wiki",
|
||||
access: "readWrite",
|
||||
readable: true,
|
||||
writable: true,
|
||||
requiredDirectories: [],
|
||||
requiredFiles: [],
|
||||
missingDirectories: [],
|
||||
missingFiles: [],
|
||||
healthy: true,
|
||||
problems: [],
|
||||
checkedAt: new Date().toISOString(),
|
||||
},
|
||||
managedAgent: {
|
||||
status: "resolved",
|
||||
source: "managed",
|
||||
agentId: "agent-1",
|
||||
resourceKey: "wiki-maintainer",
|
||||
details: { name: "Wiki Maintainer", status: "idle", adapterType: "claude_local", icon: "book-open", urlKey: "wiki-maintainer" },
|
||||
},
|
||||
managedProject: {
|
||||
status: "resolved",
|
||||
source: "managed",
|
||||
projectId: "project-1",
|
||||
resourceKey: "llm-wiki",
|
||||
details: { name: "LLM Wiki", status: "in_progress" },
|
||||
},
|
||||
managedSkills: [],
|
||||
managedRoutines: [],
|
||||
eventIngestion: {
|
||||
enabled: false,
|
||||
sources: { issues: false, comments: false, documents: false },
|
||||
wikiId: "default",
|
||||
maxCharacters: 12000,
|
||||
},
|
||||
agentOptions: [{ id: "agent-1", name: "Wiki Maintainer", status: "idle", icon: "book-open", urlKey: "wiki-maintainer" }],
|
||||
projectOptions: [{ id: "project-1", name: "LLM Wiki", status: "in_progress", color: "#2563eb" }],
|
||||
capabilities: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: () => undefined,
|
||||
};
|
||||
}
|
||||
if (key === "pages") {
|
||||
return {
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
path: "wiki/projects/control-plane/index.md",
|
||||
title: "Control plane",
|
||||
pageType: "projects",
|
||||
backlinkCount: 0,
|
||||
sourceCount: 1,
|
||||
contentHash: "abc123",
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
sources: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: () => undefined,
|
||||
};
|
||||
}
|
||||
if (key === "page-content") {
|
||||
return {
|
||||
data: {
|
||||
wikiId: "default",
|
||||
path: "wiki/projects/control-plane/index.md",
|
||||
contents: "# Control plane\n\nCurrent project state.",
|
||||
title: "Control plane",
|
||||
pageType: "projects",
|
||||
backlinks: [],
|
||||
sourceRefs: [
|
||||
{
|
||||
kind: "issue",
|
||||
title: "Distillation kickoff",
|
||||
issueId: "issue-1",
|
||||
projectId: "project-1",
|
||||
updatedAt: "2026-05-04T15:01:00Z",
|
||||
issueIdentifier: "PAP-3416",
|
||||
},
|
||||
],
|
||||
updatedAt: "2026-05-04T15:01:00Z",
|
||||
hash: "def456",
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: () => undefined,
|
||||
};
|
||||
}
|
||||
if (key === "distillation-page-provenance") {
|
||||
return { data: null, loading: false, error: null, refresh: () => undefined };
|
||||
}
|
||||
return { data: null, loading: false, error: null, refresh: () => undefined };
|
||||
},
|
||||
usePluginAction: () => async () => ({}),
|
||||
usePluginToast: () => () => undefined,
|
||||
useHostLocation: () => hostLocation,
|
||||
useHostNavigation: () => ({
|
||||
resolveHref: (to: string) => `/PAP${to.startsWith("/") ? to : `/${to}`}`,
|
||||
navigate: (to: string) => {
|
||||
navigatedTo = to;
|
||||
},
|
||||
linkProps: (to: string) => ({
|
||||
href: `/PAP${to.startsWith("/") ? to : `/${to}`}`,
|
||||
onClick: () => undefined,
|
||||
}),
|
||||
}),
|
||||
MarkdownBlock: ({ content }: { content: string }) => createElement("div", {}, content),
|
||||
MarkdownEditor: ({ value }: { value: string }) => createElement("textarea", { value, readOnly: true }),
|
||||
AssigneePicker: () => createElement("div", { "data-testid": "assignee-picker" }),
|
||||
ProjectPicker: () => createElement("div", { "data-testid": "project-picker" }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
consoleError.mockRestore();
|
||||
delete (globalThis as BridgeGlobal).__paperclipPluginBridge__;
|
||||
});
|
||||
|
||||
it("renders structured Paperclip source refs as text", () => {
|
||||
act(() => {
|
||||
root.render(createElement(WikiPage, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("PAP-3416 issue - Distillation kickoff");
|
||||
const consoleOutput = consoleError.mock.calls.flat().join("\n");
|
||||
expect(consoleOutput).not.toContain("Objects are not valid as a React child");
|
||||
expect(consoleOutput).not.toContain("Each child in a list should have a unique \"key\" prop");
|
||||
});
|
||||
|
||||
it("prioritizes file drop on the ingest page without recent ingest or cost copy", () => {
|
||||
hostLocation = {
|
||||
pathname: "/PAP/wiki/ingest",
|
||||
search: "",
|
||||
hash: "",
|
||||
};
|
||||
|
||||
act(() => {
|
||||
root.render(createElement(WikiPage, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Drop files anywhere on this page");
|
||||
expect(text).not.toContain("Recent ingests");
|
||||
expect(text).not.toContain("Why does this take a moment?");
|
||||
expect(text).not.toContain("est. cost");
|
||||
|
||||
const separatorText = container.querySelector("[data-testid='llm-wiki-ingest-manual-separator']")?.textContent ?? "";
|
||||
expect(separatorText).toBe("or");
|
||||
expect(text.indexOf("Drop files anywhere on this page")).toBeLessThan(text.indexOf("Source title"));
|
||||
expect(text.indexOf("Source title")).toBeLessThan(text.indexOf("URL"));
|
||||
expect(text.indexOf("URL")).toBeLessThan(text.indexOf("Paste markdown / text"));
|
||||
});
|
||||
|
||||
it("closes the page drop overlay when a file drag leaves without dropping files", () => {
|
||||
act(() => {
|
||||
root.render(createElement(WikiPage, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
const page = container.querySelector("main") as HTMLElement;
|
||||
act(() => {
|
||||
page.dispatchEvent(createFileDragEvent("dragenter"));
|
||||
});
|
||||
|
||||
expect(container.querySelector("[data-testid='llm-wiki-page-drop-overlay']")).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
page.dispatchEvent(createFileDragEvent("dragleave"));
|
||||
});
|
||||
|
||||
expect(container.querySelector("[data-testid='llm-wiki-page-drop-overlay']")).toBeNull();
|
||||
expect(container.querySelector("[data-testid='llm-wiki-ingest-modal']")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps staged dropped files in the ingest modal after the drop overlay clears", () => {
|
||||
act(() => {
|
||||
root.render(createElement(WikiPage, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
const page = container.querySelector("main") as HTMLElement;
|
||||
const file = new File(["source notes"], "source-notes.md", { type: "text/markdown" });
|
||||
|
||||
act(() => {
|
||||
page.dispatchEvent(createFileDragEvent("dragenter"));
|
||||
page.dispatchEvent(createFileDragEvent("drop", { files: [file] }));
|
||||
});
|
||||
|
||||
expect(container.querySelector("[data-testid='llm-wiki-page-drop-overlay']")).toBeNull();
|
||||
expect(container.querySelector("[data-testid='llm-wiki-ingest-modal']")).not.toBeNull();
|
||||
expect(container.textContent).toContain("source-notes.md");
|
||||
});
|
||||
|
||||
it("lets users close the page drop overlay directly", () => {
|
||||
act(() => {
|
||||
root.render(createElement(WikiPage, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
const page = container.querySelector("main") as HTMLElement;
|
||||
act(() => {
|
||||
page.dispatchEvent(createFileDragEvent("dragenter"));
|
||||
});
|
||||
|
||||
const closeButton = container.querySelector("[aria-label='Close ingest drop overlay']") as HTMLButtonElement;
|
||||
expect(closeButton).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
closeButton.click();
|
||||
});
|
||||
|
||||
expect(container.querySelector("[data-testid='llm-wiki-page-drop-overlay']")).toBeNull();
|
||||
});
|
||||
|
||||
it("navigates settings tabs to their URL subpaths", () => {
|
||||
hostLocation = {
|
||||
pathname: "/PAP/wiki/settings",
|
||||
search: "",
|
||||
hash: "",
|
||||
};
|
||||
|
||||
act(() => {
|
||||
root.render(createElement(WikiPage, {
|
||||
context: { companyId: COMPANY_ID, companyPrefix: "PAP" },
|
||||
} as never));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
(Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("Distillation")) as HTMLButtonElement).click();
|
||||
});
|
||||
|
||||
expect(navigatedTo).toBe("/wiki/settings/distillation");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"tests"
|
||||
],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.spec.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
# @paperclipai/plugin-kubernetes (alpha)
|
||||
|
||||
First-party Paperclip sandbox-provider plugin for Kubernetes.
|
||||
|
||||
**Alpha:** the default backend (`sandbox-cr`) is built on `kubernetes-sigs/agent-sandbox` v1alpha1 — expect breaking changes as that CRD evolves toward Beta. A stable fallback backend (`job`, using `batch/v1` Job) is available for clusters without agent-sandbox installed, but it does NOT support multi-command exec (paperclip-server's adapter-install pattern requires sandbox-cr).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### For `sandbox-cr` backend (default, recommended)
|
||||
|
||||
1. A Kubernetes cluster running k8s 1.27+
|
||||
2. [`kubernetes-sigs/agent-sandbox`](https://github.com/kubernetes-sigs/agent-sandbox) controller installed in the cluster (alpha — installs the `sandboxes.agents.x-k8s.io/v1alpha1` CRD and controller)
|
||||
3. Paperclip-server running with access to the cluster (in-cluster via `inCluster: true` or external via `kubeconfig`)
|
||||
|
||||
### For `job` backend (stable fallback)
|
||||
|
||||
1. A Kubernetes cluster running k8s 1.27+
|
||||
2. Paperclip-server with cluster access — no additional controllers or CRDs required
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
paperclipai plugin install @paperclipai/plugin-kubernetes
|
||||
```
|
||||
|
||||
Or, for local development:
|
||||
|
||||
```bash
|
||||
paperclipai plugin install --local /path/to/paperclip/packages/plugins/sandbox-providers/kubernetes
|
||||
```
|
||||
|
||||
## Backends
|
||||
|
||||
The plugin supports two backend modes, selected via the `backend` config field:
|
||||
|
||||
| Backend | Default | Stability | Multi-command exec | Requires |
|
||||
|---|---|---|---|---|
|
||||
| `sandbox-cr` | Yes | Alpha | Yes | `kubernetes-sigs/agent-sandbox` controller |
|
||||
| `job` | No | Stable | No | Nothing beyond k8s 1.27+ |
|
||||
|
||||
**`sandbox-cr` (default):** Creates a `Sandbox` CR (`agents.x-k8s.io/v1alpha1`) whose controller provisions a long-lived pod running `sleep infinity`. paperclip-server execs individual commands into the running pod — this is the multi-command adapter-install pattern. When you `releaseLease`, the Sandbox CR is deleted and the controller tears down the pod.
|
||||
|
||||
**`job` (stable fallback):** Creates a `batch/v1` Job. The container entrypoint runs once and exits — no multi-command exec possible. Use this when you cannot install agent-sandbox, or when you need strictly stable Kubernetes APIs. Note: paperclip-server's adapter-install pattern will not work in job mode.
|
||||
|
||||
### Migrating from `job` to `sandbox-cr`
|
||||
|
||||
1. Install the agent-sandbox controller: `kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/latest/download/install.yaml`
|
||||
2. Update your environment config to set `backend: "sandbox-cr"` (or remove `backend` since `sandbox-cr` is the default)
|
||||
3. New leases will use the Sandbox CR backend. Existing leases created with `job` mode continue to use job semantics until they are released.
|
||||
|
||||
## Configuration
|
||||
|
||||
Create a `sandbox` environment with `driver: kubernetes`. One of these auth fields is required:
|
||||
|
||||
- `inCluster: true` — use the in-pod ServiceAccount credentials (when paperclip-server runs inside the same cluster).
|
||||
- `kubeconfig: <YAML>` — inline kubeconfig (stored as a company secret).
|
||||
- `kubeconfigSecretRef: <secret-uuid>` — reference to an existing Paperclip secret.
|
||||
|
||||
Common optional fields:
|
||||
|
||||
| Field | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `backend` | `"sandbox-cr"` | `sandbox-cr` (alpha, requires agent-sandbox controller) or `job` (stable, one-shot entrypoint). |
|
||||
| `adapterType` | `"claude_local"` | One of the supported adapter types (claude_local, codex_local, gemini_local, cursor_local, opencode_local, acpx_local, pi_local). Determines runtime image + env keys + egress allow-list. |
|
||||
| `namespacePrefix` | `"paperclip-"` | Prefix for the per-company tenant namespace. |
|
||||
| `paperclipServerNamespace` | `"paperclip"` | Namespace where paperclip-server pods run. Generated egress policies use this so agent pods can call back to the server. |
|
||||
| `companySlug` | derived from companyId | Override the auto-derived company slug. |
|
||||
| `imageRegistry` | (none) | Override the default registry for agent runtime images. |
|
||||
| `imageAllowList` | `[]` | Glob patterns of allowed `target.imageOverride` values. Empty = no override permitted. |
|
||||
| `imagePullSecrets` | `[]` | Names of pre-created Docker image pull secrets in the tenant namespace. |
|
||||
| `egressAllowFqdns` | `[]` | Additional FQDNs (beyond adapter defaults like `api.anthropic.com`). |
|
||||
| `egressAllowCidrs` | `[]` | Additional CIDRs to allow HTTPS egress to. CIDR egress is restricted to TCP port 443. |
|
||||
| `egressMode` | `"standard"` | `standard` (NetworkPolicy + CIDRs, plus public HTTPS fallback when adapter FQDNs are configured) or `cilium` (CiliumNetworkPolicy + exact FQDN allow-list). |
|
||||
| `runtimeClassName` | (none) | e.g. `kata-fc` for Firecracker-backed microVMs. Cluster must have the RuntimeClass installed. |
|
||||
| `serviceAccountAnnotations` | `{}` | Annotations applied to per-tenant ServiceAccount (e.g. IRSA `eks.amazonaws.com/role-arn`). |
|
||||
| `jobTtlSecondsAfterFinished` | `900` | Seconds after a Job completes before garbage-collection. |
|
||||
| `podActivityDeadlineSec` | `3600` | Hard ceiling on a single run's wall-clock time. |
|
||||
|
||||
Full JSON Schema in `src/manifest.ts`.
|
||||
|
||||
## What gets created in your cluster
|
||||
|
||||
For each company that runs agents (created lazily on first dispatch):
|
||||
|
||||
```
|
||||
Namespace paperclip-{companySlug} (PSS: restricted enforce + audit)
|
||||
ServiceAccount paperclip-tenant-sa
|
||||
Role paperclip-tenant-role (only get pods/log)
|
||||
RoleBinding paperclip-tenant-rb
|
||||
ResourceQuota paperclip-quota (pods, requests/limits cpu+memory)
|
||||
LimitRange paperclip-limits (container max/min/default/defaultRequest)
|
||||
NetworkPolicy paperclip-deny-all (deny ingress + egress baseline)
|
||||
NetworkPolicy paperclip-egress-allow (DNS + paperclip-server callback + user CIDRs + public HTTPS fallback for adapter FQDNs)
|
||||
OR CiliumNetworkPolicy paperclip-egress-fqdn if egressMode=cilium
|
||||
```
|
||||
|
||||
Standard Kubernetes NetworkPolicy cannot match FQDNs. In `egressMode: "standard"`, adapter-default FQDNs such as `api.anthropic.com` trigger a public IPv4 HTTPS fallback that excludes private and link-local ranges, so default agent runs can reach model APIs without opening intra-cluster/private-network egress. Use `egressMode: "cilium"` when you need exact FQDN enforcement.
|
||||
|
||||
For each agent run (sandbox-cr backend):
|
||||
|
||||
```
|
||||
Sandbox CR pc-{ulid} (agents.x-k8s.io/v1alpha1; explicit delete on release)
|
||||
Pod pc-{ulid}-{podSuffix} (managed by Sandbox controller; torn down on CR delete)
|
||||
Secret pc-{ulid}-env (owned by Sandbox CR; cascade-deleted)
|
||||
```
|
||||
|
||||
## Fast workspace uploads
|
||||
|
||||
The `sandbox-cr` backend recognizes the chunked base64 upload protocol emitted by `@paperclipai/adapter-utils` for workspace, skill, and config-seed file transfers. Instead of running one Kubernetes exec per base64 chunk, the plugin buffers the upload in worker memory and flushes the final payload through a single `head -c <bytes> | base64 -d` exec with stdin.
|
||||
|
||||
The interceptor is intentionally narrow: only the exact `mkdir`/`printf`/`base64 -d` command shape generated by adapter-utils is optimized. Unknown commands and missing init state fall back to normal exec behavior. Uploads over the 100 MB buffer cap fail fast instead of falling back, because earlier chunks were already acknowledged without being written to the pod.
|
||||
|
||||
For each agent run (job backend):
|
||||
|
||||
```
|
||||
Job pc-{ulid} (backoffLimit: 0, ttlSecondsAfterFinished from config)
|
||||
Pod pc-{ulid}-{podSuffix} (owned by Job; cascade-deleted)
|
||||
Secret pc-{ulid}-env (owned by Job; cascade-deleted)
|
||||
```
|
||||
|
||||
## Security baseline
|
||||
|
||||
Every agent pod is:
|
||||
|
||||
- non-root (`runAsUser: 1000`, `runAsGroup: 1000`, `runAsNonRoot: true`)
|
||||
- drops ALL Linux capabilities, `allowPrivilegeEscalation: false`
|
||||
- `readOnlyRootFilesystem: true` with explicit `emptyDir` mounts for `/workspace`, `/home/paperclip`, `/home/paperclip/.cache`, `/tmp`
|
||||
- `seccompProfile: RuntimeDefault`
|
||||
- Tini as PID 1 (reaps zombies, forwards signals)
|
||||
- `fsGroupChangePolicy: OnRootMismatch` (fast PVC startup; openclaw-operator lesson)
|
||||
- `automountServiceAccountToken: false`
|
||||
|
||||
Plus per-namespace `pod-security.kubernetes.io/enforce: restricted` and a deny-all NetworkPolicy baseline with explicit egress allow-list (DNS, paperclip-server, CIDRs, and either Cilium FQDN rules or standard-mode public HTTPS fallback).
|
||||
|
||||
The per-run Secret carrying the bootstrap token and adapter API keys has `ownerReferences` pointing at the owning Sandbox CR or Job, so releasing the lease cascades cleanly to the Pod and Secret.
|
||||
|
||||
## Optional Kata-FC microVM isolation
|
||||
|
||||
For stronger isolation, install [Kata Containers](https://github.com/kata-containers/kata-containers) with the Firecracker hypervisor, then set `runtimeClassName: kata-fc` in the plugin config. Each agent pod will run inside a Firecracker microVM. Requires nested-virt-capable nodes (bare-metal or specific cloud instance types).
|
||||
|
||||
## Roadmap
|
||||
|
||||
- **Phase A (done):** `sandbox-cr` backend — multi-command exec via agent-sandbox Sandbox CRD.
|
||||
- **Phase B:** Warm pool support — pre-provisioned Sandbox CRs for sub-second cold starts. The `SandboxOrchestrator` interface reserves optional `pause?`/`resume?` extension slots.
|
||||
- **Phase C:** Kata-FC + snapshots — `runtimeClassName: kata-fc` with VM snapshot for fast restore.
|
||||
- **Phase D:** Contribute back to agent-sandbox upstream if their Beta model diverges from our needs. The `SandboxOrchestrator` interface (`src/sandbox-orchestrator.ts`) is the clean swap point — a new implementation can be added without touching `plugin.ts` business logic.
|
||||
|
||||
## Lessons learned (from openclaw-operator)
|
||||
|
||||
This plugin adopts patterns from `openclaw-rocks/openclaw-operator`:
|
||||
|
||||
- Tini PID 1 (issue #471 — zombie helper processes)
|
||||
- Read-only rootFS with explicit writable mounts (issue #456 — ~/.config not writable)
|
||||
- Strategic merge on reconcile (issue #446 — preserve third-party annotations)
|
||||
- Multi-storage-class testing (issue #448 — `local-path-provisioner` differences)
|
||||
- Image version compat matrix (issue #462 — runtime deps cannot resolve after upgrade)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cd packages/plugins/sandbox-providers/kubernetes
|
||||
pnpm install --ignore-workspace
|
||||
pnpm test # unit tests only (fast)
|
||||
pnpm typecheck
|
||||
pnpm build
|
||||
```
|
||||
|
||||
To run the kind-cluster integration test (requires `kubectl --context kind-paperclip` and a pre-loaded alpine image; see `test/integration/end-to-end-run.test.ts`):
|
||||
|
||||
```bash
|
||||
RUN_K8S_INTEGRATION_TESTS=1 pnpm test test/integration/end-to-end-run.test.ts
|
||||
```
|
||||
@@ -0,0 +1,137 @@
|
||||
# Manual smoke test — `@paperclipai/plugin-kubernetes`
|
||||
|
||||
Manual sanity check that the plugin works end-to-end against a real
|
||||
paperclip-server instance and a real Kubernetes cluster (kind for local
|
||||
dev). Future work may automate this in CI.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A running kind cluster:
|
||||
```bash
|
||||
kind create cluster --name paperclip
|
||||
```
|
||||
- `kubectl --context kind-paperclip get nodes` returns a node in `Ready` state.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Build the plugin
|
||||
|
||||
```bash
|
||||
cd packages/plugins/sandbox-providers/kubernetes
|
||||
pnpm install --ignore-workspace
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Expected: `dist/` populated with compiled `.js` and `.d.ts` files. No errors.
|
||||
|
||||
### 2. Start paperclip-server in dev mode
|
||||
|
||||
In a separate terminal:
|
||||
|
||||
```bash
|
||||
cd /path/to/paperclip
|
||||
export PAPERCLIP_HOME=/tmp/paperclip-smoke
|
||||
export PAPERCLIP_INSTANCE_ID=smoke
|
||||
export PAPERCLIP_DEPLOYMENT_MODE=local_trusted
|
||||
pnpm --filter @paperclipai/server dev
|
||||
```
|
||||
|
||||
Wait for `Server listening on 127.0.0.1:3100`.
|
||||
|
||||
### 3. Install the plugin via the CLI
|
||||
|
||||
```bash
|
||||
pnpm paperclipai plugin install \
|
||||
--local /path/to/paperclip/packages/plugins/sandbox-providers/kubernetes \
|
||||
--api-base http://127.0.0.1:3100
|
||||
```
|
||||
|
||||
Expected: `✓ Installed paperclip.kubernetes-sandbox-provider v0.1.0 (ready)`.
|
||||
|
||||
### 4. Create a company and a kubernetes sandbox environment
|
||||
|
||||
```bash
|
||||
CO_ID=$(curl -s -X POST -H "Content-Type: application/json" \
|
||||
-d '{"name":"SmokeCo"}' \
|
||||
http://127.0.0.1:3100/api/companies | jq -r '.id')
|
||||
|
||||
KUBECONFIG_CONTENT=$(cat ~/.kube/config | jq -Rs .)
|
||||
|
||||
curl -s -X POST -H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"name\": \"k8s-sandbox\",
|
||||
\"driver\": \"sandbox\",
|
||||
\"config\": {
|
||||
\"provider\": \"kubernetes\",
|
||||
\"kubeconfig\": $KUBECONFIG_CONTENT,
|
||||
\"companySlug\": \"smoke\",
|
||||
\"adapterType\": \"claude_local\",
|
||||
\"imageAllowList\": [\"ghcr.io/paperclipai/agent-runtime-claude:v1\"]
|
||||
}
|
||||
}" \
|
||||
http://127.0.0.1:3100/api/companies/$CO_ID/environments | jq
|
||||
```
|
||||
|
||||
Expected: HTTP 201 with the new environment row.
|
||||
|
||||
### 5. Probe the environment
|
||||
|
||||
```bash
|
||||
ENV_ID=$(curl -s http://127.0.0.1:3100/api/companies/$CO_ID/environments | jq -r '.[0].id')
|
||||
curl -s -X POST -d '{}' -H "Content-Type: application/json" \
|
||||
http://127.0.0.1:3100/api/environments/$ENV_ID/probe | jq
|
||||
```
|
||||
|
||||
Expected: `{"ok": true, ...}` with a summary mentioning the tenant namespace
|
||||
(`paperclip-smoke`). On first probe the namespace may not yet exist —
|
||||
the plugin treats a 404 on `listNamespacedPod` as a successful reachability
|
||||
check.
|
||||
|
||||
### 6. Trigger an agent run
|
||||
|
||||
Use the UI or the API to dispatch a run against the `k8s-sandbox` environment.
|
||||
The plugin's `onEnvironmentAcquireLease` will:
|
||||
|
||||
1. `ensureTenant` — provision the `paperclip-smoke` namespace, SA, Role,
|
||||
RoleBinding, ResourceQuota, LimitRange, NetworkPolicies
|
||||
2. `buildSandboxCrManifest` — render the security-hardened Sandbox CR manifest
|
||||
3. `createNamespacedCustomObject` — submit to `agents.x-k8s.io/v1alpha1`
|
||||
4. `createPerRunSecret` — owned by the Sandbox CR for cascade-delete
|
||||
5. Fast-upload workspace/config/skill payloads by collapsing adapter-utils chunked uploads into a single stdin-backed exec per file
|
||||
|
||||
### 7. Verify the tenant resources
|
||||
|
||||
```bash
|
||||
kubectl --context kind-paperclip get namespace paperclip-smoke
|
||||
kubectl --context kind-paperclip get all,networkpolicy,resourcequota,limitrange,sa,role,rolebinding -n paperclip-smoke
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- Namespace `paperclip-smoke` exists with PSS labels
|
||||
(`pod-security.kubernetes.io/enforce=restricted`)
|
||||
- ServiceAccount `paperclip-tenant-sa`
|
||||
- Role `paperclip-tenant-role`, RoleBinding `paperclip-tenant-rb`
|
||||
- ResourceQuota `paperclip-quota`, LimitRange `paperclip-limits`
|
||||
- NetworkPolicies `paperclip-deny-all` + `paperclip-egress-allow`
|
||||
- Sandbox `pc-{ulid}` and its managed Pod
|
||||
- Secret `pc-{ulid}-env` with `ownerReferences` pointing at the Sandbox CR
|
||||
- Run logs or plugin metadata include `fastUpload: "flush"` entries during workspace/config/skill upload
|
||||
|
||||
### 8. Tear down
|
||||
|
||||
```bash
|
||||
kubectl --context kind-paperclip delete namespace paperclip-smoke
|
||||
kill %1 # paperclip-server
|
||||
```
|
||||
|
||||
### 9. Document the result
|
||||
|
||||
In the PR description (or appended to this file as a dated section),
|
||||
record:
|
||||
|
||||
- Date + git SHA
|
||||
- `kubectl version` server version
|
||||
- Output of `kubectl get all -n paperclip-smoke` after step 6
|
||||
- Probe response from step 5
|
||||
- Time-to-acquire-lease (target: <30s on kind for a cold tenant)
|
||||
@@ -0,0 +1,22 @@
|
||||
# This plugin uses only stable Kubernetes APIs. No CRD installation is required.
|
||||
#
|
||||
# Minimum cluster version: Kubernetes 1.27+
|
||||
# - batch/v1 Job (GA since k8s 1.21)
|
||||
# - core/v1 Pod, Secret, Namespace, ServiceAccount, ResourceQuota, LimitRange (GA since k8s 1.0)
|
||||
# - rbac.authorization.k8s.io/v1 Role, RoleBinding (GA since k8s 1.8)
|
||||
# - networking.k8s.io/v1 NetworkPolicy (GA since k8s 1.7)
|
||||
# - Pod Security Standards namespace labels (GA in k8s 1.25)
|
||||
# - fsGroupChangePolicy: OnRootMismatch (GA in k8s 1.23)
|
||||
# - seccompProfile.type: RuntimeDefault (GA in k8s 1.19)
|
||||
#
|
||||
# Optional CNI prerequisites for FQDN-based egress (egressMode: cilium):
|
||||
# - Cilium >= 1.11 with hubble + DNS proxy enabled
|
||||
# - cilium.io/v2 CiliumNetworkPolicy (provided by Cilium installation)
|
||||
#
|
||||
# Optional runtime class for microVM isolation (runtimeClassName: kata-fc):
|
||||
# - kata-containers with Firecracker hypervisor
|
||||
# - nested-virt-capable nodes
|
||||
#
|
||||
# Future backends (not currently required):
|
||||
# - kubernetes-sigs/agent-sandbox (when it reaches v1beta1) as an alternative
|
||||
# backend for warm pools / templates / pause-resume.
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-kubernetes",
|
||||
"version": "0.1.0",
|
||||
"description": "Kubernetes sandbox provider plugin for Paperclip environments",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/paperclipai/paperclip",
|
||||
"bugs": {
|
||||
"url": "https://github.com/paperclipai/paperclip/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paperclipai/paperclip",
|
||||
"directory": "packages/plugins/sandbox-providers/kubernetes"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": ["dist", "manifests", "README.md"],
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js"
|
||||
},
|
||||
"keywords": [
|
||||
"paperclip",
|
||||
"plugin",
|
||||
"sandbox",
|
||||
"kubernetes"
|
||||
],
|
||||
"scripts": {
|
||||
"postinstall": "node ../../../../scripts/link-plugin-dev-sdk.mjs",
|
||||
"prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps",
|
||||
"build": "rm -rf dist && tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../../../../scripts/generate-plugin-package-json.mjs",
|
||||
"postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kubernetes/client-node": "^1.0.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
export interface AdapterDefaults {
|
||||
runtimeImage: string;
|
||||
envKeys: string[];
|
||||
allowFqdns: string[];
|
||||
probeCommand: string[];
|
||||
}
|
||||
|
||||
const REGISTRY: Record<string, AdapterDefaults> = {
|
||||
claude_local: {
|
||||
runtimeImage: "ghcr.io/paperclipai/agent-runtime-claude:v1",
|
||||
envKeys: ["ANTHROPIC_API_KEY"],
|
||||
allowFqdns: ["api.anthropic.com"],
|
||||
probeCommand: ["claude", "--version"],
|
||||
},
|
||||
codex_local: {
|
||||
runtimeImage: "ghcr.io/paperclipai/agent-runtime-codex:v1",
|
||||
envKeys: ["OPENAI_API_KEY"],
|
||||
allowFqdns: ["api.openai.com"],
|
||||
probeCommand: ["codex", "--version"],
|
||||
},
|
||||
gemini_local: {
|
||||
runtimeImage: "ghcr.io/paperclipai/agent-runtime-gemini:v1",
|
||||
envKeys: ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
|
||||
allowFqdns: ["generativelanguage.googleapis.com"],
|
||||
probeCommand: ["gemini", "--version"],
|
||||
},
|
||||
cursor_local: {
|
||||
runtimeImage: "ghcr.io/paperclipai/agent-runtime-cursor:v1",
|
||||
envKeys: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
|
||||
allowFqdns: ["api.anthropic.com", "api.openai.com"],
|
||||
probeCommand: ["cursor-agent", "--version"],
|
||||
},
|
||||
opencode_local: {
|
||||
runtimeImage: "ghcr.io/paperclipai/agent-runtime-opencode:v1",
|
||||
envKeys: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY"],
|
||||
allowFqdns: ["api.anthropic.com", "api.openai.com", "openrouter.ai"],
|
||||
probeCommand: ["opencode", "--version"],
|
||||
},
|
||||
acpx_local: {
|
||||
runtimeImage: "ghcr.io/paperclipai/agent-runtime-acpx:v1",
|
||||
envKeys: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
|
||||
allowFqdns: ["api.anthropic.com", "api.openai.com"],
|
||||
probeCommand: ["acpx", "--version"],
|
||||
},
|
||||
pi_local: {
|
||||
runtimeImage: "ghcr.io/paperclipai/agent-runtime-pi:v1",
|
||||
envKeys: ["ANTHROPIC_API_KEY"],
|
||||
allowFqdns: ["api.anthropic.com"],
|
||||
probeCommand: ["pi", "--version"],
|
||||
},
|
||||
};
|
||||
|
||||
export const KNOWN_ADAPTER_TYPES: ReadonlySet<string> = new Set(Object.keys(REGISTRY));
|
||||
|
||||
export function getAdapterDefaults(adapterType: string): AdapterDefaults {
|
||||
const defaults = REGISTRY[adapterType];
|
||||
if (!defaults) {
|
||||
throw new Error(`Unknown adapter type: ${adapterType}`);
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
export interface BuildCiliumNetworkPolicyInput {
|
||||
namespace: string;
|
||||
paperclipServerNamespace: string;
|
||||
egressAllowFqdns: string[];
|
||||
egressAllowCidrs: string[];
|
||||
}
|
||||
|
||||
// Design note: no ingress rules are defined here. Paperclip-server does NOT
|
||||
// push to agent pods — agents make outbound (egress) callbacks to
|
||||
// paperclip-server on port 3100. If server→agent push is ever needed, add a
|
||||
// targeted ingress rule scoped to the paperclip-server endpoint selector.
|
||||
export function buildCiliumNetworkPolicyManifest(input: BuildCiliumNetworkPolicyInput): Record<string, unknown> {
|
||||
const egress: Record<string, unknown>[] = [];
|
||||
|
||||
egress.push({
|
||||
toEndpoints: [
|
||||
{ matchLabels: { "k8s:io.kubernetes.pod.namespace": "kube-system", "k8s-app": "kube-dns" } },
|
||||
],
|
||||
toPorts: [
|
||||
{
|
||||
ports: [
|
||||
{ port: "53", protocol: "UDP" },
|
||||
{ port: "53", protocol: "TCP" },
|
||||
],
|
||||
rules: { dns: [{ matchPattern: "*" }] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (input.egressAllowFqdns.length > 0) {
|
||||
egress.push({
|
||||
toFQDNs: input.egressAllowFqdns.map((fqdn) => ({ matchName: fqdn })),
|
||||
toPorts: [{ ports: [{ port: "443", protocol: "TCP" }] }],
|
||||
});
|
||||
}
|
||||
|
||||
egress.push({
|
||||
toEndpoints: [
|
||||
{
|
||||
matchLabels: {
|
||||
"k8s:io.kubernetes.pod.namespace": input.paperclipServerNamespace,
|
||||
app: "paperclip-server",
|
||||
},
|
||||
},
|
||||
],
|
||||
toPorts: [{ ports: [{ port: "3100", protocol: "TCP" }] }],
|
||||
});
|
||||
|
||||
if (input.egressAllowCidrs.length > 0) {
|
||||
egress.push({
|
||||
toCIDRSet: input.egressAllowCidrs.map((cidr) => ({ cidr })),
|
||||
toPorts: [{ ports: [{ port: "443", protocol: "TCP" }] }],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
apiVersion: "cilium.io/v2",
|
||||
kind: "CiliumNetworkPolicy",
|
||||
metadata: {
|
||||
name: "paperclip-egress-fqdn",
|
||||
namespace: input.namespace,
|
||||
labels: { "paperclip.io/managed-by": "paperclip-k8s-plugin" },
|
||||
},
|
||||
spec: {
|
||||
endpointSelector: { matchLabels: { "paperclip.io/role": "agent" } },
|
||||
egress,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Glob matching for image references.
|
||||
* - `*` matches any sequence of characters EXCEPT `/` (so a wildcard doesn't span path segments)
|
||||
* - `?` matches exactly one character (excluding `/`)
|
||||
*/
|
||||
export function globMatch(pattern: string, value: string): boolean {
|
||||
const re = new RegExp(
|
||||
"^" +
|
||||
pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*/g, "[^/]*")
|
||||
.replace(/\?/g, "[^/]") +
|
||||
"$",
|
||||
);
|
||||
return re.test(value);
|
||||
}
|
||||
|
||||
export interface ResolveImageInput {
|
||||
imageOverride?: string | null;
|
||||
}
|
||||
|
||||
export interface ResolveImageDefaults {
|
||||
runtimeImage: string;
|
||||
}
|
||||
|
||||
export interface ResolveImageConfig {
|
||||
imageAllowList: string[];
|
||||
imageRegistry?: string;
|
||||
}
|
||||
|
||||
export function resolveImage(
|
||||
target: ResolveImageInput,
|
||||
defaults: ResolveImageDefaults,
|
||||
config: ResolveImageConfig,
|
||||
): string {
|
||||
if (target.imageOverride) {
|
||||
if (!config.imageAllowList.some((p) => globMatch(p, target.imageOverride!))) {
|
||||
throw new Error(`Image override "${target.imageOverride}" is not in allowlist`);
|
||||
}
|
||||
return target.imageOverride;
|
||||
}
|
||||
if (config.imageRegistry) {
|
||||
return rewriteRegistry(defaults.runtimeImage, config.imageRegistry);
|
||||
}
|
||||
return defaults.runtimeImage;
|
||||
}
|
||||
|
||||
function rewriteRegistry(image: string, registry: string): string {
|
||||
// image is like "ghcr.io/paperclipai/agent-runtime-claude:v1"
|
||||
// we want to replace the first two path segments (host + org) with `registry`
|
||||
const cleanRegistry = registry.replace(/\/+$/, "");
|
||||
const colonIdx = image.lastIndexOf(":");
|
||||
const tag = colonIdx >= 0 ? image.slice(colonIdx) : "";
|
||||
const path = colonIdx >= 0 ? image.slice(0, colonIdx) : image;
|
||||
const segments = path.split("/");
|
||||
// Strip the host+org (first two segments), keep the image name
|
||||
const imageName = segments.slice(2).join("/") || segments[segments.length - 1];
|
||||
return `${cleanRegistry}/${imageName}${tag}`;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as manifest } from "./manifest.js";
|
||||
export { default as plugin } from "./plugin.js";
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { KubeClients } from "./kube-client.js";
|
||||
import type { SandboxOrchestrator, SandboxStatus } from "./sandbox-orchestrator.js";
|
||||
|
||||
export class JobTimeoutError extends Error {
|
||||
constructor(namespace: string, name: string, timeoutMs: number) {
|
||||
super(`Job ${namespace}/${name} did not complete within ${timeoutMs}ms`);
|
||||
this.name = "JobTimeoutError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function createJob(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
manifest: Record<string, unknown>,
|
||||
): Promise<{ uid: string }> {
|
||||
const result = await clients.batch.createNamespacedJob({ namespace, body: manifest as never });
|
||||
const uid = (result as { metadata?: { uid?: string } }).metadata?.uid;
|
||||
if (!uid) throw new Error("Job created without a UID");
|
||||
return { uid };
|
||||
}
|
||||
|
||||
export type JobStatus = SandboxStatus;
|
||||
|
||||
export async function getJobStatus(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
name: string,
|
||||
): Promise<JobStatus> {
|
||||
const result = await clients.batch.readNamespacedJobStatus({ namespace, name });
|
||||
const body = (result as Record<string, unknown>) ?? {};
|
||||
const status = (body.status as Record<string, unknown>) ?? {};
|
||||
const active = (status.active as number) ?? 0;
|
||||
const succeeded = (status.succeeded as number) ?? 0;
|
||||
const failed = (status.failed as number) ?? 0;
|
||||
const conditions = (status.conditions as { type: string; status: string; reason?: string; message?: string }[]) ?? [];
|
||||
const completed = conditions.find((c) => c.type === "Complete" && c.status === "True");
|
||||
const failedCond = conditions.find((c) => c.type === "Failed" && c.status === "True");
|
||||
if (failedCond || failed > 0) {
|
||||
return { phase: "Failed", complete: false, active, succeeded, failed, reason: failedCond?.reason, message: failedCond?.message };
|
||||
}
|
||||
if (completed || succeeded > 0) {
|
||||
return { phase: "Succeeded", complete: true, active, succeeded, failed };
|
||||
}
|
||||
if (active > 0) {
|
||||
return { phase: "Running", complete: false, active, succeeded, failed };
|
||||
}
|
||||
return { phase: "Pending", complete: false, active, succeeded, failed };
|
||||
}
|
||||
|
||||
export async function findPodForJob(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
jobName: string,
|
||||
): Promise<string | null> {
|
||||
const result = await clients.core.listNamespacedPod({
|
||||
namespace,
|
||||
labelSelector: `job-name=${jobName}`,
|
||||
});
|
||||
const items = ((result as { items?: { metadata?: { name?: string }; status?: { phase?: string } }[] }).items) ?? [];
|
||||
const running = items.find((p) => p.status?.phase === "Running");
|
||||
return (running ?? items[0])?.metadata?.name ?? null;
|
||||
}
|
||||
|
||||
export async function streamPodLogs(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
podName: string,
|
||||
onChunk: (stream: "stdout" | "stderr", text: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
// V1 limitation: the Pod log API returns the container's combined log stream.
|
||||
// Kubernetes does not preserve stdout/stderr channel separation after the
|
||||
// container runtime writes logs, so the Job backend reports combined logs on
|
||||
// stdout. The sandbox-cr backend uses exec and keeps streams separate.
|
||||
const result = await clients.core.readNamespacedPodLog({ namespace, name: podName });
|
||||
const text = readPodLogText(result);
|
||||
if (text.length > 0) await onChunk("stdout", text);
|
||||
}
|
||||
|
||||
function readPodLogText(result: unknown): string {
|
||||
if (typeof result === "string") return result;
|
||||
const body = (result as { body?: unknown })?.body;
|
||||
return typeof body === "string" ? body : "";
|
||||
}
|
||||
|
||||
export async function deleteJob(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
await clients.batch.deleteNamespacedJob({
|
||||
namespace,
|
||||
name,
|
||||
propagationPolicy: "Foreground",
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForJobCompletion(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
name: string,
|
||||
opts: { timeoutMs: number; pollMs?: number } = { timeoutMs: 120_000, pollMs: 2000 },
|
||||
): Promise<JobStatus> {
|
||||
const deadline = Date.now() + opts.timeoutMs;
|
||||
const pollMs = opts.pollMs ?? 2000;
|
||||
while (Date.now() < deadline) {
|
||||
const status = await getJobStatus(clients, namespace, name);
|
||||
if (status.phase === "Succeeded" || status.phase === "Failed") return status;
|
||||
await sleep(pollMs);
|
||||
}
|
||||
throw new JobTimeoutError(namespace, name, opts.timeoutMs);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Job-backed conformance to SandboxOrchestrator. Plugin.ts imports THIS value
|
||||
* (the swap point) — to use a different backend, swap this import for another
|
||||
* module exposing a SandboxOrchestrator-shaped default export.
|
||||
*/
|
||||
export const jobOrchestrator: SandboxOrchestrator = {
|
||||
claim: createJob,
|
||||
getStatus: getJobStatus,
|
||||
findPod: findPodForJob,
|
||||
streamLogs: streamPodLogs,
|
||||
release: deleteJob,
|
||||
waitForCompletion: waitForJobCompletion,
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
KubeConfig,
|
||||
CoreV1Api,
|
||||
BatchV1Api,
|
||||
CustomObjectsApi,
|
||||
NetworkingV1Api,
|
||||
RbacAuthorizationV1Api,
|
||||
} from "@kubernetes/client-node";
|
||||
|
||||
export interface CreateKubeConfigInput {
|
||||
inCluster?: boolean;
|
||||
kubeconfig?: string;
|
||||
}
|
||||
|
||||
export function createKubeConfig(input: CreateKubeConfigInput): KubeConfig {
|
||||
const kc = new KubeConfig();
|
||||
if (input.inCluster) {
|
||||
kc.loadFromCluster();
|
||||
return kc;
|
||||
}
|
||||
if (input.kubeconfig && input.kubeconfig.trim().length > 0) {
|
||||
kc.loadFromString(input.kubeconfig);
|
||||
return kc;
|
||||
}
|
||||
throw new Error("createKubeConfig requires either inCluster=true or a kubeconfig string");
|
||||
}
|
||||
|
||||
export interface KubeClients {
|
||||
core: CoreV1Api;
|
||||
batch: BatchV1Api;
|
||||
custom: CustomObjectsApi;
|
||||
networking: NetworkingV1Api;
|
||||
rbac: RbacAuthorizationV1Api;
|
||||
}
|
||||
|
||||
export function makeKubeClients(kc: KubeConfig): KubeClients {
|
||||
return {
|
||||
core: kc.makeApiClient(CoreV1Api),
|
||||
batch: kc.makeApiClient(BatchV1Api),
|
||||
custom: kc.makeApiClient(CustomObjectsApi),
|
||||
networking: kc.makeApiClient(NetworkingV1Api),
|
||||
rbac: kc.makeApiClient(RbacAuthorizationV1Api),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const PLUGIN_ID = "paperclip.kubernetes-sandbox-provider";
|
||||
const PLUGIN_VERSION = "0.1.0-alpha.1";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: PLUGIN_ID,
|
||||
apiVersion: 1,
|
||||
version: PLUGIN_VERSION,
|
||||
displayName: "Kubernetes Sandbox (alpha)",
|
||||
description:
|
||||
"Built on kubernetes-sigs/agent-sandbox (v1alpha1). ALPHA — expect breaking changes as the upstream CRD evolves. Falls back to stable batch/v1 Job mode for clusters without agent-sandbox installed. First-party Paperclip sandbox-provider plugin for Kubernetes.",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["environment.drivers.register"],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
},
|
||||
environmentDrivers: [
|
||||
{
|
||||
driverKey: "kubernetes",
|
||||
kind: "sandbox_provider",
|
||||
displayName: "Kubernetes",
|
||||
description:
|
||||
"Dispatches agent runs in per-tenant Kubernetes namespaces. Default backend (sandbox-cr, alpha) uses kubernetes-sigs/agent-sandbox for multi-command exec; fallback backend (job) uses stable batch/v1 Job for clusters without agent-sandbox installed.",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
inCluster: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"When true, the plugin uses the in-pod ServiceAccount credentials. Requires paperclip-server to be running inside the target cluster.",
|
||||
},
|
||||
kubeconfig: {
|
||||
type: "string",
|
||||
format: "secret-ref",
|
||||
pattern: "\\S",
|
||||
description:
|
||||
"Inline kubeconfig YAML. Paste a kubeconfig or an existing Paperclip secret reference; pasted values are stored as company secrets.",
|
||||
},
|
||||
namespacePrefix: {
|
||||
type: "string",
|
||||
maxLength: 20,
|
||||
description: "Prefix for the per-company tenant namespace (default: paperclip-).",
|
||||
},
|
||||
paperclipServerNamespace: {
|
||||
type: "string",
|
||||
maxLength: 63,
|
||||
pattern: "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
|
||||
description:
|
||||
"Namespace where paperclip-server pods run. Used by generated egress policies so agent pods can call back to the server (default: paperclip).",
|
||||
},
|
||||
companySlug: {
|
||||
type: "string",
|
||||
maxLength: 43,
|
||||
description: "Override the auto-derived company slug used in the tenant namespace name.",
|
||||
},
|
||||
imageRegistry: {
|
||||
type: "string",
|
||||
description: "Override the default registry for agent runtime images (default: ghcr.io/paperclipai).",
|
||||
},
|
||||
imageAllowList: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description:
|
||||
"Glob patterns of allowed `target.imageOverride` values. Empty list = no override permitted.",
|
||||
},
|
||||
imagePullSecrets: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Names of pre-created Docker image pull secrets in the tenant namespace.",
|
||||
},
|
||||
egressAllowFqdns: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description:
|
||||
"Additional FQDNs to allow egress to from agent pods. Adapter-default FQDNs (e.g. api.anthropic.com) are added automatically.",
|
||||
},
|
||||
egressAllowCidrs: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Additional CIDRs to allow HTTPS egress to from agent pods. CIDR egress is restricted to TCP port 443.",
|
||||
},
|
||||
egressMode: {
|
||||
type: "string",
|
||||
enum: ["standard", "cilium"],
|
||||
description:
|
||||
"Network policy mode. `standard` uses NetworkPolicy and allows public HTTPS when adapter FQDNs are configured; `cilium` enables exact FQDN egress filtering via CiliumNetworkPolicy.",
|
||||
},
|
||||
runtimeClassName: {
|
||||
type: "string",
|
||||
description:
|
||||
"Optional RuntimeClass for pod isolation (e.g. `kata-fc` for Firecracker-backed microVMs). Cluster must have the RuntimeClass installed.",
|
||||
},
|
||||
serviceAccountAnnotations: {
|
||||
type: "object",
|
||||
additionalProperties: { type: "string" },
|
||||
description:
|
||||
"Annotations applied to the per-tenant ServiceAccount (e.g. `eks.amazonaws.com/role-arn` for IRSA).",
|
||||
},
|
||||
jobTtlSecondsAfterFinished: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
description: "Seconds after a Job completes before it is garbage-collected (default: 900).",
|
||||
},
|
||||
podActivityDeadlineSec: {
|
||||
type: "integer",
|
||||
minimum: 1,
|
||||
description: "Hard ceiling on a single run's wall-clock time (default: 3600).",
|
||||
},
|
||||
adapterType: {
|
||||
type: "string",
|
||||
description:
|
||||
"The adapter type that Jobs in this environment will run (e.g. `claude_local`, `codex_local`). Defaults to `claude_local`. Each environment is bound to one adapter; create multiple environments for different adapters.",
|
||||
},
|
||||
backend: {
|
||||
type: "string",
|
||||
enum: ["sandbox-cr", "job"],
|
||||
description:
|
||||
"sandbox-cr (default, alpha — requires kubernetes-sigs/agent-sandbox installed) | job (stable fallback — batch/v1 Job, one-shot entrypoint, no multi-command exec)",
|
||||
},
|
||||
},
|
||||
anyOf: [
|
||||
{
|
||||
properties: { inCluster: { const: true } },
|
||||
required: ["inCluster"],
|
||||
},
|
||||
{ required: ["kubeconfig"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,102 @@
|
||||
export interface BuildNetworkPolicyInput {
|
||||
namespace: string;
|
||||
paperclipServerNamespace: string;
|
||||
egressAllowFqdns: string[];
|
||||
egressAllowCidrs: string[];
|
||||
}
|
||||
|
||||
const PUBLIC_IPV4_EXCEPTIONS = [
|
||||
"10.0.0.0/8",
|
||||
"100.64.0.0/10",
|
||||
"127.0.0.0/8",
|
||||
"169.254.0.0/16",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
];
|
||||
|
||||
// Design note: the deny-all baseline blocks all ingress to agent pods.
|
||||
// Paperclip-server does NOT push to agent pods — the agent shim makes
|
||||
// outbound calls to paperclip-server via the egress allow-list (port 3100).
|
||||
// This pull/callback model means no ingress rule is needed. If a future
|
||||
// feature requires server→agent push (e.g. forced shutdown, live exec),
|
||||
// add a targeted ingress rule here scoped to the paperclip-server pod
|
||||
// selector.
|
||||
//
|
||||
// Standard Kubernetes NetworkPolicy cannot express FQDN allow-lists. When
|
||||
// adapter defaults require FQDN egress, keep runs functional by allowing public
|
||||
// IPv4 HTTPS while excluding private/link-local ranges. Operators who need
|
||||
// exact FQDN enforcement should use egressMode="cilium".
|
||||
export function buildNetworkPolicyManifests(input: BuildNetworkPolicyInput): Record<string, unknown>[] {
|
||||
const fqdnsRequirePublicHttpsFallback = input.egressAllowFqdns.length > 0;
|
||||
const denyAll = {
|
||||
apiVersion: "networking.k8s.io/v1",
|
||||
kind: "NetworkPolicy",
|
||||
metadata: {
|
||||
name: "paperclip-deny-all",
|
||||
namespace: input.namespace,
|
||||
labels: { "paperclip.io/managed-by": "paperclip-k8s-plugin" },
|
||||
},
|
||||
spec: {
|
||||
podSelector: {},
|
||||
policyTypes: ["Ingress", "Egress"],
|
||||
},
|
||||
};
|
||||
|
||||
const egressAllow: Record<string, unknown> = {
|
||||
apiVersion: "networking.k8s.io/v1",
|
||||
kind: "NetworkPolicy",
|
||||
metadata: {
|
||||
name: "paperclip-egress-allow",
|
||||
namespace: input.namespace,
|
||||
labels: { "paperclip.io/managed-by": "paperclip-k8s-plugin" },
|
||||
},
|
||||
spec: {
|
||||
podSelector: { matchLabels: { "paperclip.io/role": "agent" } },
|
||||
policyTypes: ["Egress"],
|
||||
egress: [
|
||||
{
|
||||
to: [
|
||||
{
|
||||
namespaceSelector: { matchLabels: { "kubernetes.io/metadata.name": "kube-system" } },
|
||||
podSelector: { matchLabels: { "k8s-app": "kube-dns" } },
|
||||
},
|
||||
],
|
||||
ports: [
|
||||
{ protocol: "UDP", port: 53 },
|
||||
{ protocol: "TCP", port: 53 },
|
||||
],
|
||||
},
|
||||
{
|
||||
to: [
|
||||
{
|
||||
namespaceSelector: { matchLabels: { "kubernetes.io/metadata.name": input.paperclipServerNamespace } },
|
||||
podSelector: { matchLabels: { app: "paperclip-server" } },
|
||||
},
|
||||
],
|
||||
ports: [{ protocol: "TCP", port: 3100 }],
|
||||
},
|
||||
...(fqdnsRequirePublicHttpsFallback
|
||||
? [
|
||||
{
|
||||
to: [
|
||||
{
|
||||
ipBlock: {
|
||||
cidr: "0.0.0.0/0",
|
||||
except: PUBLIC_IPV4_EXCEPTIONS,
|
||||
},
|
||||
},
|
||||
],
|
||||
ports: [{ protocol: "TCP", port: 443 }],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...input.egressAllowCidrs.map((cidr) => ({
|
||||
to: [{ ipBlock: { cidr } }],
|
||||
ports: [{ protocol: "TCP", port: 443 }],
|
||||
})),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
return [denyAll, egressAllow];
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { definePlugin } from "@paperclipai/plugin-sdk";
|
||||
import type {
|
||||
PluginEnvironmentAcquireLeaseParams,
|
||||
PluginEnvironmentExecuteParams,
|
||||
PluginEnvironmentExecuteResult,
|
||||
PluginEnvironmentLease,
|
||||
PluginEnvironmentProbeParams,
|
||||
PluginEnvironmentProbeResult,
|
||||
PluginEnvironmentRealizeWorkspaceParams,
|
||||
PluginEnvironmentRealizeWorkspaceResult,
|
||||
PluginEnvironmentReleaseLeaseParams,
|
||||
PluginEnvironmentValidateConfigParams,
|
||||
PluginEnvironmentValidationResult,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
import {
|
||||
kubernetesProviderConfigSchema,
|
||||
type KubernetesProviderConfig,
|
||||
type KubernetesLeaseMetadata,
|
||||
} from "./types.js";
|
||||
import { createKubeConfig, makeKubeClients } from "./kube-client.js";
|
||||
import { getAdapterDefaults } from "./adapter-defaults.js";
|
||||
import { resolveImage } from "./image-allowlist.js";
|
||||
import { buildJobManifest } from "./pod-spec-builder.js";
|
||||
import { buildSandboxCrManifest } from "./sandbox-cr-builder.js";
|
||||
import { ensureTenant } from "./tenant-orchestrator.js";
|
||||
import { createPerRunSecret } from "./secret-manager.js";
|
||||
import { FastUploadInterceptor } from "./upload-interceptor.js";
|
||||
import { jobOrchestrator, JobTimeoutError } from "./job-orchestrator.js";
|
||||
import {
|
||||
sandboxCrOrchestrator,
|
||||
SandboxCrTimeoutError,
|
||||
} from "./sandbox-cr-orchestrator.js";
|
||||
import { execInPod } from "./pod-exec.js";
|
||||
import { shellQuoteArg } from "./shell-utils.js";
|
||||
import {
|
||||
deriveCompanySlug,
|
||||
deriveNamespaceName,
|
||||
newRunUlidDns,
|
||||
paperclipLabels,
|
||||
} from "./utils.js";
|
||||
|
||||
// Name of the ServiceAccount created inside each tenant namespace by ensureTenant.
|
||||
const TENANT_SERVICE_ACCOUNT = "paperclip-tenant-sa";
|
||||
|
||||
// Resource quota defaults applied to every tenant namespace (M4b; tunable via
|
||||
// config in a future milestone).
|
||||
const DEFAULT_RESOURCE_QUOTA = {
|
||||
pods: "20",
|
||||
requestsCpu: "10",
|
||||
requestsMemory: "20Gi",
|
||||
limitsCpu: "20",
|
||||
limitsMemory: "40Gi",
|
||||
};
|
||||
|
||||
const uploadInterceptorsByLease = new Map<string, FastUploadInterceptor>();
|
||||
|
||||
function getOrCreateUploadInterceptor(leaseId: string): FastUploadInterceptor {
|
||||
let interceptor = uploadInterceptorsByLease.get(leaseId);
|
||||
if (!interceptor) {
|
||||
interceptor = new FastUploadInterceptor();
|
||||
uploadInterceptorsByLease.set(leaseId, interceptor);
|
||||
}
|
||||
return interceptor;
|
||||
}
|
||||
|
||||
function extractShellScript(
|
||||
params: Pick<PluginEnvironmentExecuteParams, "args" | "command">,
|
||||
): string | null {
|
||||
const command = typeof params.command === "string" ? params.command.trim() : "";
|
||||
const args = Array.isArray(params.args) ? params.args : [];
|
||||
const isShell = command === "sh" || command === "bash" || command.endsWith("/sh") || command.endsWith("/bash");
|
||||
if (isShell && args[0] === "-c" && typeof args[1] === "string") {
|
||||
return args[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deriveTenantNamespace(config: KubernetesProviderConfig, companyId: string): string {
|
||||
// TODO: future versions could thread companyName through AcquireLeaseParams
|
||||
// to get a friendlier slug (e.g. "acme-corp") instead of the UUID-derived one.
|
||||
const slug = config.companySlug ?? deriveCompanySlug(companyId);
|
||||
return deriveNamespaceName(config.namespacePrefix, slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads adapter env keys (e.g. ANTHROPIC_API_KEY) from the current process
|
||||
* environment. The plugin worker runs inside paperclip-server's pod, which has
|
||||
* these vars injected at deploy time.
|
||||
*
|
||||
* M4b approach: env vars sourced from process.env at acquire time.
|
||||
* TODO: future milestones may thread per-run secrets differently (e.g. via
|
||||
* a secret store reference on the environment config).
|
||||
*/
|
||||
export function extractAdapterEnvFromProcess(
|
||||
envKeys: string[],
|
||||
warn: (message: string) => void = console.warn,
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
const missing: string[] = [];
|
||||
for (const k of envKeys) {
|
||||
const v = process.env[k];
|
||||
if (v !== undefined) {
|
||||
out[k] = v;
|
||||
} else {
|
||||
missing.push(k);
|
||||
}
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
warn(
|
||||
`[plugin-kubernetes] adapter environment variable(s) missing from plugin worker process: ${missing.join(", ")}. Agent pods may fail provider authentication unless these keys are optional for the selected adapter.`,
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildSandboxExecCommand(
|
||||
params: Pick<PluginEnvironmentExecuteParams, "args" | "command">,
|
||||
): string[] {
|
||||
const command = typeof params.command === "string" ? params.command.trim() : "";
|
||||
const args = Array.isArray(params.args) ? params.args : [];
|
||||
|
||||
if (command.length > 0 && args.length > 0) {
|
||||
return [command, ...args];
|
||||
}
|
||||
if (command.length > 0) {
|
||||
return ["/bin/sh", "-lc", command];
|
||||
}
|
||||
if (args.length > 0) {
|
||||
return ["/bin/sh", "-lc", args.map(shellQuoteArg).join(" ")];
|
||||
}
|
||||
return ["/bin/sh", "-l"];
|
||||
}
|
||||
|
||||
export function deriveUploadTargetDir(targetPath: string): string {
|
||||
const slashIndex = targetPath.lastIndexOf("/");
|
||||
return slashIndex >= 0 ? targetPath.slice(0, slashIndex) || "/" : ".";
|
||||
}
|
||||
|
||||
export function buildSandboxExecShellCommand(
|
||||
params: Pick<PluginEnvironmentExecuteParams, "args" | "command">,
|
||||
): string {
|
||||
if (typeof params.command === "string" && params.command.trim().length > 0) {
|
||||
return params.command;
|
||||
}
|
||||
|
||||
return params.args?.map(shellQuoteArg).join(" ") ?? "";
|
||||
}
|
||||
|
||||
function generateBootstrapToken(): string {
|
||||
// TODO: paperclip-server's actual callback auth scheme is separate and is
|
||||
// out of M4b scope. This per-run random token is stored in the per-run
|
||||
// Secret and consumed by paperclip-agent-shim for initial registration.
|
||||
return randomBytes(32).toString("hex");
|
||||
}
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.logger.info("Kubernetes sandbox provider plugin ready");
|
||||
},
|
||||
|
||||
async onHealth() {
|
||||
return { status: "ok", message: "Kubernetes sandbox provider plugin healthy" };
|
||||
},
|
||||
|
||||
async onEnvironmentValidateConfig(
|
||||
params: PluginEnvironmentValidateConfigParams,
|
||||
): Promise<PluginEnvironmentValidationResult> {
|
||||
const parsed = kubernetesProviderConfigSchema.safeParse(params.config);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: parsed.error.issues.map((i) => i.message),
|
||||
};
|
||||
}
|
||||
const warnings: string[] = [];
|
||||
const cfg = parsed.data;
|
||||
const adapterDefaults = getAdapterDefaults(cfg.adapterType);
|
||||
const totalFqdns = [...adapterDefaults.allowFqdns, ...cfg.egressAllowFqdns];
|
||||
if (cfg.egressMode === "standard" && totalFqdns.length > 0) {
|
||||
warnings.push(
|
||||
`egressMode=standard cannot enforce FQDN-based egress rules for ${totalFqdns.join(", ")}. Agent pods will get public IPv4 HTTPS egress with private/link-local ranges excluded. Switch egressMode to "cilium" for exact FQDN enforcement.`,
|
||||
);
|
||||
}
|
||||
return { ok: true, normalizedConfig: cfg as Record<string, unknown>, warnings: warnings.length > 0 ? warnings : undefined };
|
||||
},
|
||||
|
||||
async onEnvironmentProbe(
|
||||
params: PluginEnvironmentProbeParams,
|
||||
): Promise<PluginEnvironmentProbeResult> {
|
||||
const parsed = kubernetesProviderConfigSchema.safeParse(params.config);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
summary: "Invalid Kubernetes provider configuration.",
|
||||
metadata: {
|
||||
errors: parsed.error.issues.map((i) => i.message),
|
||||
},
|
||||
};
|
||||
}
|
||||
const config = parsed.data;
|
||||
const namespace = deriveTenantNamespace(config, params.companyId);
|
||||
|
||||
try {
|
||||
const kc = createKubeConfig({
|
||||
inCluster: config.inCluster,
|
||||
kubeconfig: config.kubeconfig,
|
||||
});
|
||||
const clients = makeKubeClients(kc);
|
||||
// Reachability check: list pods in the tenant namespace. If the namespace
|
||||
// doesn't exist yet this will throw a 404 which we treat as "reachable
|
||||
// but namespace not provisioned" — still a successful probe.
|
||||
try {
|
||||
await clients.core.listNamespacedPod({ namespace });
|
||||
} catch (err) {
|
||||
const code = (err as { code?: number; statusCode?: number }).code
|
||||
?? (err as { code?: number; statusCode?: number }).statusCode;
|
||||
if (code !== 404) throw err;
|
||||
// 404 means namespace doesn't exist yet — cluster is reachable.
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
summary: `Kubernetes cluster reachable. Tenant namespace: ${namespace}.`,
|
||||
metadata: { namespace, provider: "kubernetes" },
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
summary: "Kubernetes cluster probe failed.",
|
||||
metadata: {
|
||||
namespace,
|
||||
provider: "kubernetes",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
async onEnvironmentAcquireLease(
|
||||
params: PluginEnvironmentAcquireLeaseParams,
|
||||
): Promise<PluginEnvironmentLease> {
|
||||
const config = kubernetesProviderConfigSchema.parse(params.config);
|
||||
const namespace = deriveTenantNamespace(config, params.companyId);
|
||||
|
||||
// Emit a runtime warning if FQDNs are configured but egressMode=standard
|
||||
// cannot enforce them. Mirrors the validateConfig warning so operators see
|
||||
// it in paperclip-server logs even if they missed the validation step.
|
||||
const adapterDefaultsForWarn = getAdapterDefaults(config.adapterType);
|
||||
const totalFqdnsForWarn = [...adapterDefaultsForWarn.allowFqdns, ...config.egressAllowFqdns];
|
||||
if (config.egressMode === "standard" && totalFqdnsForWarn.length > 0) {
|
||||
// The SDK does not currently thread ctx.logger into environment hooks.
|
||||
// Keep this explicit so operators still see the standard-mode egress
|
||||
// trade-off in raw worker logs.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[plugin-kubernetes] egressMode=standard cannot enforce FQDN-based egress rules for ${totalFqdnsForWarn.join(", ")}. Agent pods will get public IPv4 HTTPS egress with private/link-local ranges excluded. Switch egressMode to "cilium" for exact FQDN enforcement.`,
|
||||
);
|
||||
}
|
||||
|
||||
const kc = createKubeConfig({
|
||||
inCluster: config.inCluster,
|
||||
kubeconfig: config.kubeconfig,
|
||||
});
|
||||
const clients = makeKubeClients(kc);
|
||||
|
||||
// Ensure the tenant namespace and all its RBAC / network policy resources
|
||||
// exist before we try to create the Job.
|
||||
const adapterDefaults = getAdapterDefaults(config.adapterType);
|
||||
|
||||
await ensureTenant(clients, {
|
||||
namespace,
|
||||
companyId: params.companyId,
|
||||
paperclipServerNamespace: config.paperclipServerNamespace,
|
||||
serviceAccountAnnotations: config.serviceAccountAnnotations,
|
||||
egressMode: config.egressMode,
|
||||
egressAllowFqdns: [...adapterDefaults.allowFqdns, ...config.egressAllowFqdns],
|
||||
egressAllowCidrs: config.egressAllowCidrs,
|
||||
resourceQuota: DEFAULT_RESOURCE_QUOTA,
|
||||
});
|
||||
|
||||
const jobName = `pc-${newRunUlidDns()}`;
|
||||
const secretName = `${jobName}-env`;
|
||||
|
||||
// TODO: use params.runId as stand-in for agentId in labels; future
|
||||
// versions will have a dedicated agentId on AcquireLeaseParams.
|
||||
const labels = paperclipLabels({
|
||||
runId: params.runId,
|
||||
agentId: params.runId,
|
||||
companyId: params.companyId,
|
||||
adapterType: config.adapterType,
|
||||
});
|
||||
|
||||
const image = resolveImage(
|
||||
{ imageOverride: null },
|
||||
adapterDefaults,
|
||||
{ imageAllowList: config.imageAllowList, imageRegistry: config.imageRegistry },
|
||||
);
|
||||
|
||||
// Pick the orchestrator and build the appropriate manifest based on backend.
|
||||
const isSandboxCrBackend = config.backend === "sandbox-cr";
|
||||
const orchestrator = isSandboxCrBackend ? sandboxCrOrchestrator : jobOrchestrator;
|
||||
|
||||
const manifest = isSandboxCrBackend
|
||||
? buildSandboxCrManifest({
|
||||
namespace,
|
||||
sandboxName: jobName,
|
||||
adapterType: config.adapterType,
|
||||
image,
|
||||
envSecretName: secretName,
|
||||
serviceAccountName: TENANT_SERVICE_ACCOUNT,
|
||||
labels,
|
||||
resources: config.defaultResources ?? {},
|
||||
runtimeClassName: config.runtimeClassName,
|
||||
imagePullSecrets: config.imagePullSecrets,
|
||||
})
|
||||
: buildJobManifest({
|
||||
namespace,
|
||||
jobName,
|
||||
adapterType: config.adapterType,
|
||||
image,
|
||||
envSecretName: secretName,
|
||||
serviceAccountName: TENANT_SERVICE_ACCOUNT,
|
||||
labels,
|
||||
resources: config.defaultResources ?? {},
|
||||
runtimeClassName: config.runtimeClassName,
|
||||
activeDeadlineSec: config.podActivityDeadlineSec,
|
||||
ttlSecondsAfterFinished: config.jobTtlSecondsAfterFinished,
|
||||
imagePullSecrets: config.imagePullSecrets,
|
||||
});
|
||||
|
||||
const { uid: ownerUid } = await orchestrator.claim(clients, namespace, manifest);
|
||||
|
||||
// M4b: adapter env vars are sourced from the plugin worker's own process
|
||||
// environment (paperclip-server pod has them injected at deploy time).
|
||||
const adapterEnv = extractAdapterEnvFromProcess(adapterDefaults.envKeys);
|
||||
const bootstrapToken = generateBootstrapToken();
|
||||
|
||||
// Secret ownerRef: for job backend, the Job owns the Secret (cascade delete).
|
||||
// For sandbox-cr backend, the Sandbox CR owns the Secret.
|
||||
// NOTE: For sandbox-cr, if the Secret outlives the Sandbox due to a cluster
|
||||
// quirk, the release() call will still clean it up via namespace GC or
|
||||
// explicit delete in a future milestone.
|
||||
try {
|
||||
await createPerRunSecret(clients, {
|
||||
namespace,
|
||||
secretName,
|
||||
runId: params.runId,
|
||||
ownerKind: isSandboxCrBackend ? "Sandbox" : "Job",
|
||||
ownerApiVersion: isSandboxCrBackend ? "agents.x-k8s.io/v1alpha1" : "batch/v1",
|
||||
ownerName: jobName,
|
||||
ownerUid,
|
||||
bootstrapToken,
|
||||
adapterEnv,
|
||||
});
|
||||
|
||||
const podName = await orchestrator.findPod(clients, namespace, jobName);
|
||||
|
||||
const leaseMetadata: KubernetesLeaseMetadata = {
|
||||
namespace,
|
||||
jobName,
|
||||
podName,
|
||||
secretName,
|
||||
phase: "Pending",
|
||||
backend: config.backend,
|
||||
};
|
||||
|
||||
return {
|
||||
providerLeaseId: jobName,
|
||||
metadata: leaseMetadata as unknown as Record<string, unknown>,
|
||||
};
|
||||
} catch (err) {
|
||||
try {
|
||||
await orchestrator.release(clients, namespace, jobName);
|
||||
} catch (cleanupErr) {
|
||||
throw new Error(
|
||||
`Kubernetes lease setup failed and cleanup also failed: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
async onEnvironmentRealizeWorkspace(
|
||||
params: PluginEnvironmentRealizeWorkspaceParams,
|
||||
): Promise<PluginEnvironmentRealizeWorkspaceResult> {
|
||||
// The agent pod already has /workspace mounted as an emptyDir at pod
|
||||
// scheduling time (see pod-spec-builder). Nothing to provision here —
|
||||
// we just hand back the cwd. Honor a caller-supplied remotePath if set.
|
||||
const cwd =
|
||||
params.workspace.remotePath && params.workspace.remotePath.trim().length > 0
|
||||
? params.workspace.remotePath.trim()
|
||||
: "/workspace";
|
||||
return {
|
||||
cwd,
|
||||
metadata: {
|
||||
provider: "kubernetes",
|
||||
remoteCwd: cwd,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async onEnvironmentReleaseLease(
|
||||
params: PluginEnvironmentReleaseLeaseParams,
|
||||
): Promise<void> {
|
||||
if (!params.providerLeaseId) return;
|
||||
const config = kubernetesProviderConfigSchema.parse(params.config);
|
||||
const namespace =
|
||||
typeof params.leaseMetadata?.namespace === "string"
|
||||
? params.leaseMetadata.namespace
|
||||
: deriveTenantNamespace(config, params.companyId);
|
||||
|
||||
const kc = createKubeConfig({
|
||||
inCluster: config.inCluster,
|
||||
kubeconfig: config.kubeconfig,
|
||||
});
|
||||
const clients = makeKubeClients(kc);
|
||||
|
||||
const leaseBackend =
|
||||
typeof params.leaseMetadata?.backend === "string"
|
||||
? (params.leaseMetadata.backend as "sandbox-cr" | "job")
|
||||
: config.backend;
|
||||
const releaseOrchestrator =
|
||||
leaseBackend === "sandbox-cr" ? sandboxCrOrchestrator : jobOrchestrator;
|
||||
|
||||
uploadInterceptorsByLease.delete(params.providerLeaseId);
|
||||
|
||||
try {
|
||||
await releaseOrchestrator.release(clients, namespace, params.providerLeaseId);
|
||||
} catch (err) {
|
||||
// If the resource is already gone (404), that's fine.
|
||||
const code = (err as { code?: number; statusCode?: number }).code
|
||||
?? (err as { code?: number; statusCode?: number }).statusCode;
|
||||
if (code !== 404) throw err;
|
||||
}
|
||||
},
|
||||
|
||||
async onEnvironmentExecute(
|
||||
params: PluginEnvironmentExecuteParams,
|
||||
): Promise<PluginEnvironmentExecuteResult> {
|
||||
const { lease, timeoutMs } = params;
|
||||
|
||||
if (!lease.providerLeaseId) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "No provider lease ID available for execution.",
|
||||
};
|
||||
}
|
||||
|
||||
const config = kubernetesProviderConfigSchema.parse(params.config);
|
||||
const namespace =
|
||||
typeof lease.metadata?.namespace === "string"
|
||||
? lease.metadata.namespace
|
||||
: deriveTenantNamespace(config, params.companyId);
|
||||
|
||||
// Determine which backend this lease was created with.
|
||||
const leaseBackend =
|
||||
typeof lease.metadata?.backend === "string"
|
||||
? (lease.metadata.backend as "sandbox-cr" | "job")
|
||||
: config.backend;
|
||||
|
||||
const kc = createKubeConfig({
|
||||
inCluster: config.inCluster,
|
||||
kubeconfig: config.kubeconfig,
|
||||
});
|
||||
const clients = makeKubeClients(kc);
|
||||
|
||||
const effectiveTimeoutMs =
|
||||
typeof timeoutMs === "number" && timeoutMs > 0
|
||||
? timeoutMs
|
||||
: config.podActivityDeadlineSec * 1000;
|
||||
|
||||
if (leaseBackend === "sandbox-cr") {
|
||||
// ── Sandbox-CR backend ──────────────────────────────────────────────────
|
||||
// 1. Ensure the Sandbox pod is Ready (wait if needed).
|
||||
// 2. Exec the command into the running pod.
|
||||
// 3. Return exec result directly (no log scraping needed).
|
||||
const executeStartedAt = Date.now();
|
||||
|
||||
let podName =
|
||||
typeof lease.metadata?.podName === "string" && lease.metadata.podName
|
||||
? lease.metadata.podName
|
||||
: null;
|
||||
|
||||
// Wait for pod Ready if we don't have a pod name yet (or as a health check).
|
||||
try {
|
||||
await sandboxCrOrchestrator.waitForCompletion(
|
||||
clients,
|
||||
namespace,
|
||||
lease.providerLeaseId,
|
||||
{ timeoutMs: effectiveTimeoutMs, pollMs: 2000 },
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxCrTimeoutError) {
|
||||
return {
|
||||
exitCode: null,
|
||||
timedOut: true,
|
||||
stdout: "",
|
||||
stderr: `Sandbox pod did not become Ready within ${effectiveTimeoutMs}ms`,
|
||||
metadata: {
|
||||
provider: "kubernetes",
|
||||
backend: "sandbox-cr",
|
||||
namespace,
|
||||
sandboxName: lease.providerLeaseId,
|
||||
},
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Resolve pod name (may now be populated in Sandbox status).
|
||||
if (!podName) {
|
||||
podName = await sandboxCrOrchestrator.findPod(
|
||||
clients,
|
||||
namespace,
|
||||
lease.providerLeaseId,
|
||||
);
|
||||
}
|
||||
|
||||
if (!podName) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "Sandbox pod is Ready but podName could not be resolved.",
|
||||
metadata: {
|
||||
provider: "kubernetes",
|
||||
backend: "sandbox-cr",
|
||||
namespace,
|
||||
sandboxName: lease.providerLeaseId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const remainingTimeoutMs = Math.max(1, effectiveTimeoutMs - (Date.now() - executeStartedAt));
|
||||
|
||||
const shellScript = extractShellScript(params);
|
||||
if (shellScript) {
|
||||
const decision = getOrCreateUploadInterceptor(lease.providerLeaseId).decide(shellScript);
|
||||
if (decision.action === "ack") {
|
||||
return {
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
metadata: {
|
||||
provider: "kubernetes",
|
||||
backend: "sandbox-cr",
|
||||
namespace,
|
||||
sandboxName: lease.providerLeaseId,
|
||||
podName,
|
||||
fastUpload: "ack",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (decision.action === "flush") {
|
||||
const base64Body = decision.flush.payload.toString("base64");
|
||||
const dir = deriveUploadTargetDir(decision.flush.targetPath);
|
||||
const script =
|
||||
`mkdir -p ${shellQuoteArg(dir)} && ` +
|
||||
`base64 -d > ${shellQuoteArg(decision.flush.targetPath)}`;
|
||||
const flushResult = await execInPod(
|
||||
kc,
|
||||
namespace,
|
||||
podName,
|
||||
"agent",
|
||||
["/bin/sh", "-c", script],
|
||||
base64Body,
|
||||
remainingTimeoutMs,
|
||||
);
|
||||
return {
|
||||
exitCode: flushResult.exitCode,
|
||||
timedOut: flushResult.timedOut,
|
||||
stdout: flushResult.stdout,
|
||||
stderr: flushResult.stderr,
|
||||
metadata: {
|
||||
provider: "kubernetes",
|
||||
backend: "sandbox-cr",
|
||||
namespace,
|
||||
sandboxName: lease.providerLeaseId,
|
||||
podName,
|
||||
fastUpload: "flush",
|
||||
uploadedBytes: decision.flush.payload.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (decision.action === "error") {
|
||||
return {
|
||||
exitCode: 1,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: decision.message,
|
||||
metadata: {
|
||||
provider: "kubernetes",
|
||||
backend: "sandbox-cr",
|
||||
namespace,
|
||||
sandboxName: lease.providerLeaseId,
|
||||
podName,
|
||||
fastUpload: "error",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const execCommand = buildSandboxExecCommand(params);
|
||||
const execResult = await execInPod(
|
||||
kc,
|
||||
namespace,
|
||||
podName,
|
||||
"agent",
|
||||
execCommand,
|
||||
typeof params.stdin === "string" ? params.stdin : undefined,
|
||||
remainingTimeoutMs,
|
||||
);
|
||||
|
||||
return {
|
||||
exitCode: execResult.exitCode,
|
||||
timedOut: execResult.timedOut,
|
||||
stdout: execResult.stdout,
|
||||
stderr: execResult.stderr,
|
||||
metadata: {
|
||||
provider: "kubernetes",
|
||||
backend: "sandbox-cr",
|
||||
namespace,
|
||||
sandboxName: lease.providerLeaseId,
|
||||
podName,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// ── Job backend (legacy / stable fallback) ──────────────────────────────
|
||||
// The container entrypoint is baked into the Job spec (Tini + paperclip-agent-shim).
|
||||
// We do NOT re-exec command/args — instead we wait for the Job to finish
|
||||
// and collect its logs.
|
||||
//
|
||||
// params.command / params.args / params.stdin are intentionally ignored.
|
||||
|
||||
let status;
|
||||
let timedOut = false;
|
||||
try {
|
||||
status = await jobOrchestrator.waitForCompletion(
|
||||
clients,
|
||||
namespace,
|
||||
lease.providerLeaseId,
|
||||
{ timeoutMs: effectiveTimeoutMs, pollMs: 2000 },
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof JobTimeoutError) {
|
||||
timedOut = true;
|
||||
status = null;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect logs from the pod.
|
||||
const podName =
|
||||
typeof lease.metadata?.podName === "string"
|
||||
? lease.metadata.podName
|
||||
: await jobOrchestrator.findPod(
|
||||
clients,
|
||||
namespace,
|
||||
lease.providerLeaseId,
|
||||
);
|
||||
|
||||
const stdoutChunks: string[] = [];
|
||||
const stderrChunks: string[] = [];
|
||||
|
||||
if (podName) {
|
||||
await jobOrchestrator.streamLogs(
|
||||
clients,
|
||||
namespace,
|
||||
podName,
|
||||
async (stream, text) => {
|
||||
if (stream === "stdout") stdoutChunks.push(text);
|
||||
else stderrChunks.push(text);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
exitCode: timedOut ? null : status?.phase === "Succeeded" ? 0 : 1,
|
||||
timedOut,
|
||||
stdout: stdoutChunks.join(""),
|
||||
stderr: stderrChunks.join(""),
|
||||
metadata: {
|
||||
provider: "kubernetes",
|
||||
backend: "job",
|
||||
namespace,
|
||||
jobName: lease.providerLeaseId,
|
||||
podName: podName ?? null,
|
||||
phase: status?.phase ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Exec a command inside a running pod container using the Kubernetes exec API.
|
||||
*
|
||||
* Uses @kubernetes/client-node's Exec class, which opens a WebSocket to the
|
||||
* kube-apiserver and streams stdout/stderr. The statusCallback receives a V1Status
|
||||
* with status="Success" or status="Failure" + details.causes[{reason:"ExitCode"}].
|
||||
*
|
||||
* NOTE: tty=false so stdout and stderr arrive on separate channels. If tty=true
|
||||
* were used, they would be merged onto stdout and the exit code would not be
|
||||
* reliable from the status callback on older cluster versions.
|
||||
*/
|
||||
|
||||
import { Exec } from "@kubernetes/client-node";
|
||||
import { PassThrough } from "node:stream";
|
||||
import type { KubeConfig } from "@kubernetes/client-node";
|
||||
import { shellQuoteArg } from "./shell-utils.js";
|
||||
|
||||
type WebSocketLike = {
|
||||
close(): void;
|
||||
on(event: "close", listener: (code: number, reason: Buffer) => void): void;
|
||||
on(event: "error", listener: (err: Error) => void): void;
|
||||
};
|
||||
|
||||
export interface ExecInPodResult {
|
||||
exitCode: number;
|
||||
timedOut: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export async function execInPod(
|
||||
kc: KubeConfig,
|
||||
namespace: string,
|
||||
podName: string,
|
||||
containerName: string,
|
||||
command: string[],
|
||||
stdin?: string | Buffer,
|
||||
timeoutMs?: number,
|
||||
): Promise<ExecInPodResult> {
|
||||
const exec = new Exec(kc);
|
||||
const stdoutStream = new PassThrough();
|
||||
const stderrStream = new PassThrough();
|
||||
|
||||
const stdinPayload: Buffer | null =
|
||||
Buffer.isBuffer(stdin) ? stdin
|
||||
: typeof stdin === "string" && stdin.length > 0 ? Buffer.from(stdin, "utf-8")
|
||||
: null;
|
||||
const stdinStream: PassThrough | null = stdinPayload ? new PassThrough() : null;
|
||||
const effectiveCommand = stdinPayload
|
||||
? ["/bin/sh", "-c", `head -c ${stdinPayload.length} | ${command.map(shellQuoteArg).join(" ")}`]
|
||||
: command;
|
||||
|
||||
let stdoutData = "";
|
||||
let stderrData = "";
|
||||
|
||||
stdoutStream.on("data", (chunk: Buffer) => {
|
||||
stdoutData += chunk.toString("utf-8");
|
||||
});
|
||||
stderrStream.on("data", (chunk: Buffer) => {
|
||||
stderrData += chunk.toString("utf-8");
|
||||
});
|
||||
stdoutStream.on("error", () => {});
|
||||
stderrStream.on("error", () => {});
|
||||
|
||||
return await new Promise<ExecInPodResult>(
|
||||
(resolve, reject) => {
|
||||
let ws: WebSocketLike | null = null;
|
||||
let settled = false;
|
||||
let pendingResult: Omit<ExecInPodResult, "stdout" | "stderr"> | null = null;
|
||||
let stdoutEnded = false;
|
||||
let stderrEnded = false;
|
||||
const timeout =
|
||||
typeof timeoutMs === "number" && timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
finishWithTransportFailure(`Kubernetes exec timed out after ${timeoutMs}ms`, true);
|
||||
}, timeoutMs)
|
||||
: null;
|
||||
|
||||
const finish = (result: ExecInPodResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
try {
|
||||
ws?.close();
|
||||
} catch {
|
||||
// Ignore best-effort close failures.
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
const finishWithTransportFailure = (message: string, timedOut = false) => {
|
||||
const separator = stderrData.length > 0 && !stderrData.endsWith("\n") ? "\n" : "";
|
||||
finish({
|
||||
exitCode: 1,
|
||||
timedOut,
|
||||
stdout: stdoutData,
|
||||
stderr: `${stderrData}${separator}${message}`,
|
||||
});
|
||||
};
|
||||
const tryFinish = () => {
|
||||
if (settled || !pendingResult || !stdoutEnded || !stderrEnded) return;
|
||||
finish({
|
||||
...pendingResult,
|
||||
stdout: stdoutData,
|
||||
stderr: stderrData,
|
||||
});
|
||||
};
|
||||
const endOutputStreams = () => {
|
||||
if (!stdoutStream.writableEnded) stdoutStream.end();
|
||||
if (!stderrStream.writableEnded) stderrStream.end();
|
||||
};
|
||||
|
||||
stdoutStream.on("end", () => {
|
||||
stdoutEnded = true;
|
||||
tryFinish();
|
||||
});
|
||||
stderrStream.on("end", () => {
|
||||
stderrEnded = true;
|
||||
tryFinish();
|
||||
});
|
||||
|
||||
const websocketPromise = exec
|
||||
.exec(
|
||||
namespace,
|
||||
podName,
|
||||
containerName,
|
||||
effectiveCommand,
|
||||
stdoutStream,
|
||||
stderrStream,
|
||||
stdinStream,
|
||||
false, // tty=false: keep stdout/stderr on separate channels
|
||||
(status) => {
|
||||
// status.status is "Success" | "Failure"
|
||||
if (status.status === "Success") {
|
||||
pendingResult = { exitCode: 0, timedOut: false };
|
||||
endOutputStreams();
|
||||
tryFinish();
|
||||
return;
|
||||
}
|
||||
// On failure, the exit code surfaces via
|
||||
// status.details?.causes[].{reason:"ExitCode", message:"<N>"}
|
||||
const causes = status.details?.causes ?? [];
|
||||
const exitCodeCause = causes.find(
|
||||
(c: { reason?: string; message?: string }) =>
|
||||
c.reason === "ExitCode",
|
||||
);
|
||||
const exitCode = exitCodeCause?.message
|
||||
? Number(exitCodeCause.message)
|
||||
: 1;
|
||||
pendingResult = { exitCode, timedOut: false };
|
||||
endOutputStreams();
|
||||
tryFinish();
|
||||
},
|
||||
);
|
||||
|
||||
websocketPromise
|
||||
.then((webSocket) => {
|
||||
ws = webSocket as WebSocketLike;
|
||||
if (settled) {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// Ignore best-effort close failures.
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (stdinStream && stdinPayload) {
|
||||
stdinStream.end(stdinPayload);
|
||||
}
|
||||
ws.on("close", (code: number, reason: Buffer) => {
|
||||
if (settled || pendingResult) return;
|
||||
const reasonText = reason.length > 0 ? `: ${reason.toString("utf-8")}` : "";
|
||||
finishWithTransportFailure(`Kubernetes exec websocket closed before status frame (${code})${reasonText}`);
|
||||
});
|
||||
ws.on("error", (err: Error) => {
|
||||
if (settled || pendingResult) return;
|
||||
finishWithTransportFailure(`Kubernetes exec websocket failed before status frame: ${err.message}`);
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
if (settled) return;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
export interface BuildJobManifestInput {
|
||||
namespace: string;
|
||||
jobName: string;
|
||||
adapterType: string;
|
||||
image: string;
|
||||
envSecretName: string;
|
||||
serviceAccountName: string;
|
||||
labels: Record<string, string>;
|
||||
resources: {
|
||||
requests?: { cpu?: string; memory?: string };
|
||||
limits?: { cpu?: string; memory?: string };
|
||||
};
|
||||
runtimeClassName?: string;
|
||||
activeDeadlineSec: number;
|
||||
ttlSecondsAfterFinished: number;
|
||||
imagePullSecrets?: string[];
|
||||
}
|
||||
|
||||
export function buildJobManifest(input: BuildJobManifestInput): Record<string, unknown> {
|
||||
const podLabels = {
|
||||
...input.labels,
|
||||
"paperclip.io/role": "agent",
|
||||
};
|
||||
return {
|
||||
apiVersion: "batch/v1",
|
||||
kind: "Job",
|
||||
metadata: {
|
||||
name: input.jobName,
|
||||
namespace: input.namespace,
|
||||
labels: { ...input.labels },
|
||||
},
|
||||
spec: {
|
||||
backoffLimit: 0,
|
||||
ttlSecondsAfterFinished: input.ttlSecondsAfterFinished,
|
||||
activeDeadlineSeconds: input.activeDeadlineSec,
|
||||
template: {
|
||||
metadata: { labels: podLabels },
|
||||
spec: {
|
||||
serviceAccountName: input.serviceAccountName,
|
||||
// Agent containers call back to paperclip-server via HTTPS egress;
|
||||
// they never call the Kubernetes API, so mounting an SA token is
|
||||
// unnecessary attack surface.
|
||||
automountServiceAccountToken: false,
|
||||
restartPolicy: "Never",
|
||||
...(input.runtimeClassName ? { runtimeClassName: input.runtimeClassName } : {}),
|
||||
...(input.imagePullSecrets && input.imagePullSecrets.length > 0
|
||||
? { imagePullSecrets: input.imagePullSecrets.map((name) => ({ name })) }
|
||||
: {}),
|
||||
securityContext: {
|
||||
runAsNonRoot: true,
|
||||
runAsUser: 1000,
|
||||
runAsGroup: 1000,
|
||||
fsGroup: 1000,
|
||||
fsGroupChangePolicy: "OnRootMismatch",
|
||||
seccompProfile: { type: "RuntimeDefault" },
|
||||
},
|
||||
containers: [
|
||||
{
|
||||
name: "agent",
|
||||
image: input.image,
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
command: ["/usr/bin/tini", "--", "/usr/local/bin/paperclip-agent-shim"],
|
||||
envFrom: [{ secretRef: { name: input.envSecretName } }],
|
||||
securityContext: {
|
||||
runAsNonRoot: true,
|
||||
runAsUser: 1000,
|
||||
runAsGroup: 1000,
|
||||
readOnlyRootFilesystem: true,
|
||||
allowPrivilegeEscalation: false,
|
||||
capabilities: { drop: ["ALL"] },
|
||||
},
|
||||
resources: {
|
||||
requests: input.resources.requests ?? { cpu: "250m", memory: "512Mi" },
|
||||
limits: input.resources.limits ?? { cpu: "2", memory: "4Gi" },
|
||||
},
|
||||
volumeMounts: [
|
||||
{ name: "workspace", mountPath: "/workspace" },
|
||||
{ name: "home", mountPath: "/home/paperclip" },
|
||||
{ name: "cache", mountPath: "/home/paperclip/.cache" },
|
||||
{ name: "tmp", mountPath: "/tmp" },
|
||||
],
|
||||
},
|
||||
],
|
||||
volumes: [
|
||||
{ name: "workspace", emptyDir: { sizeLimit: "8Gi" } },
|
||||
{ name: "home", emptyDir: { sizeLimit: "1Gi" } },
|
||||
{ name: "cache", emptyDir: { sizeLimit: "1Gi" } },
|
||||
{ name: "tmp", emptyDir: { sizeLimit: "2Gi" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Builds a kubernetes-sigs/agent-sandbox Sandbox CR manifest.
|
||||
*
|
||||
* The Sandbox CR creates a long-lived pod (sleep infinity entrypoint) into
|
||||
* which paperclip-server can exec arbitrary commands. This solves the
|
||||
* architectural mismatch with the batch/v1 Job backend, which only supports
|
||||
* a single one-shot entrypoint — not the multi-command adapter-install pattern
|
||||
* used by paperclip-server.
|
||||
*
|
||||
* Security baseline is identical to buildJobManifest (pod-spec-builder.ts):
|
||||
* non-root, drop ALL caps, read-only rootFS, Tini PID 1, seccomp
|
||||
* RuntimeDefault, fsGroupChangePolicy OnRootMismatch, automountSAToken=false.
|
||||
*
|
||||
* NOTE: paperclip-server runs OUTSIDE the cluster, so we cannot set ownerReferences
|
||||
* on the Sandbox CR (the owner would need to be an in-cluster resource). The
|
||||
* release path is explicit delete via sandboxCrOrchestrator.release().
|
||||
*/
|
||||
|
||||
export interface BuildSandboxCrManifestInput {
|
||||
namespace: string;
|
||||
sandboxName: string;
|
||||
adapterType: string;
|
||||
image: string;
|
||||
envSecretName: string;
|
||||
serviceAccountName: string;
|
||||
labels: Record<string, string>;
|
||||
resources: {
|
||||
requests?: { cpu?: string; memory?: string };
|
||||
limits?: { cpu?: string; memory?: string };
|
||||
};
|
||||
runtimeClassName?: string;
|
||||
imagePullSecrets?: string[];
|
||||
}
|
||||
|
||||
export function buildSandboxCrManifest(
|
||||
input: BuildSandboxCrManifestInput,
|
||||
): Record<string, unknown> {
|
||||
const podLabels: Record<string, string> = {
|
||||
...input.labels,
|
||||
"paperclip.io/role": "agent",
|
||||
};
|
||||
return {
|
||||
apiVersion: "agents.x-k8s.io/v1alpha1",
|
||||
kind: "Sandbox",
|
||||
metadata: {
|
||||
name: input.sandboxName,
|
||||
namespace: input.namespace,
|
||||
labels: { ...input.labels },
|
||||
// No ownerReferences: paperclip-server is out-of-cluster. Release is
|
||||
// explicit delete.
|
||||
},
|
||||
spec: {
|
||||
podTemplate: {
|
||||
metadata: {
|
||||
labels: podLabels,
|
||||
},
|
||||
spec: {
|
||||
serviceAccountName: input.serviceAccountName,
|
||||
// Agent containers call back to paperclip-server via HTTPS egress;
|
||||
// they never call the Kubernetes API, so mounting an SA token is
|
||||
// unnecessary attack surface.
|
||||
automountServiceAccountToken: false,
|
||||
// Sandbox controller requires restartPolicy: Always so the pod
|
||||
// stays running between exec calls.
|
||||
restartPolicy: "Always",
|
||||
...(input.runtimeClassName
|
||||
? { runtimeClassName: input.runtimeClassName }
|
||||
: {}),
|
||||
...(input.imagePullSecrets && input.imagePullSecrets.length > 0
|
||||
? {
|
||||
imagePullSecrets: input.imagePullSecrets.map((name) => ({
|
||||
name,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
securityContext: {
|
||||
runAsNonRoot: true,
|
||||
runAsUser: 1000,
|
||||
runAsGroup: 1000,
|
||||
fsGroup: 1000,
|
||||
fsGroupChangePolicy: "OnRootMismatch",
|
||||
seccompProfile: { type: "RuntimeDefault" },
|
||||
},
|
||||
containers: [
|
||||
{
|
||||
name: "agent",
|
||||
image: input.image,
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
// sleep infinity keeps the pod running; paperclip-server execs
|
||||
// commands into it via Kubernetes exec API. Tini as PID 1 for
|
||||
// proper signal forwarding and zombie reaping.
|
||||
command: [
|
||||
"/usr/bin/tini",
|
||||
"--",
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"sleep infinity",
|
||||
],
|
||||
envFrom: [{ secretRef: { name: input.envSecretName } }],
|
||||
securityContext: {
|
||||
runAsNonRoot: true,
|
||||
runAsUser: 1000,
|
||||
runAsGroup: 1000,
|
||||
readOnlyRootFilesystem: true,
|
||||
allowPrivilegeEscalation: false,
|
||||
capabilities: { drop: ["ALL"] },
|
||||
},
|
||||
resources: {
|
||||
requests: input.resources.requests ?? {
|
||||
cpu: "250m",
|
||||
memory: "512Mi",
|
||||
},
|
||||
limits: input.resources.limits ?? {
|
||||
cpu: "2",
|
||||
memory: "4Gi",
|
||||
},
|
||||
},
|
||||
volumeMounts: [
|
||||
{ name: "workspace", mountPath: "/workspace" },
|
||||
{ name: "home", mountPath: "/home/paperclip" },
|
||||
{ name: "cache", mountPath: "/home/paperclip/.cache" },
|
||||
{ name: "tmp", mountPath: "/tmp" },
|
||||
],
|
||||
},
|
||||
],
|
||||
volumes: [
|
||||
{ name: "workspace", emptyDir: { sizeLimit: "8Gi" } },
|
||||
{ name: "home", emptyDir: { sizeLimit: "1Gi" } },
|
||||
{ name: "cache", emptyDir: { sizeLimit: "1Gi" } },
|
||||
{ name: "tmp", emptyDir: { sizeLimit: "2Gi" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* SandboxOrchestrator implementation backed by the kubernetes-sigs/agent-sandbox
|
||||
* Sandbox CRD (agents.x-k8s.io/v1alpha1).
|
||||
*
|
||||
* The Sandbox CR creates a long-lived pod that paperclip-server can exec into
|
||||
* for multi-command adapter-install workflows — the key architectural win over
|
||||
* the batch/v1 Job backend.
|
||||
*
|
||||
* Key semantic differences from jobOrchestrator:
|
||||
* - claim() creates a Sandbox CR via CustomObjectsApi instead of a batch Job
|
||||
* - getStatus() maps Sandbox phase (Pending|Ready|Terminating|Failed) to SandboxStatus
|
||||
* - findPod() reads status.podName from the Sandbox CR (falls back to label query)
|
||||
* - waitForCompletion() means "wait until pod is Ready to exec" NOT "wait until
|
||||
* workload finishes". The Sandbox pod runs sleep infinity; execution completion
|
||||
* is tracked by the individual execInPod() calls.
|
||||
* - release() deletes the Sandbox CR with Foreground propagation (controller
|
||||
* tears down the underlying pod).
|
||||
*
|
||||
* NOTE: streamLogs() is provided for interface conformance but is limited —
|
||||
* the sleep-infinity pod has no meaningful stdout. Callers in execute mode
|
||||
* should use execInPod() and capture its stdout/stderr directly.
|
||||
*/
|
||||
|
||||
import type { KubeClients } from "./kube-client.js";
|
||||
import type { SandboxOrchestrator, SandboxStatus } from "./sandbox-orchestrator.js";
|
||||
|
||||
const SANDBOX_GROUP = "agents.x-k8s.io";
|
||||
const SANDBOX_VERSION = "v1alpha1";
|
||||
const SANDBOX_PLURAL = "sandboxes";
|
||||
|
||||
export class SandboxCrTimeoutError extends Error {
|
||||
constructor(namespace: string, name: string, timeoutMs: number) {
|
||||
super(
|
||||
`Sandbox ${namespace}/${name} did not reach Ready phase within ${timeoutMs}ms`,
|
||||
);
|
||||
this.name = "SandboxCrTimeoutError";
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Sandbox CR status.phase value to our SandboxStatus shape.
|
||||
* Sandbox phases: Pending | Ready | Terminating | Failed
|
||||
*/
|
||||
function mapSandboxPhase(
|
||||
cr: Record<string, unknown>,
|
||||
): SandboxStatus {
|
||||
const status = (cr.status as Record<string, unknown>) ?? {};
|
||||
const phase = (status.phase as string) ?? "Pending";
|
||||
|
||||
switch (phase) {
|
||||
case "Ready":
|
||||
return {
|
||||
phase: "Running", // SandboxStatus.phase uses Job semantics; "Running" = active pod
|
||||
complete: false,
|
||||
active: 1,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
};
|
||||
case "Terminating":
|
||||
return {
|
||||
phase: "Running",
|
||||
complete: false,
|
||||
active: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
reason: "Terminating",
|
||||
};
|
||||
case "Failed": {
|
||||
const conditions = (status.conditions as { type?: string; reason?: string; message?: string }[]) ?? [];
|
||||
const failedCond = conditions.find((c) => c.type === "Failed");
|
||||
return {
|
||||
phase: "Failed",
|
||||
complete: false,
|
||||
active: 0,
|
||||
succeeded: 0,
|
||||
failed: 1,
|
||||
reason: failedCond?.reason,
|
||||
message: failedCond?.message,
|
||||
};
|
||||
}
|
||||
default:
|
||||
// "Pending" or unknown
|
||||
return {
|
||||
phase: "Pending",
|
||||
complete: false,
|
||||
active: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSandboxCr(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
manifest: Record<string, unknown>,
|
||||
): Promise<{ uid: string }> {
|
||||
const result = await clients.custom.createNamespacedCustomObject({
|
||||
group: SANDBOX_GROUP,
|
||||
version: SANDBOX_VERSION,
|
||||
namespace,
|
||||
plural: SANDBOX_PLURAL,
|
||||
body: manifest,
|
||||
});
|
||||
const uid = (result as { metadata?: { uid?: string } }).metadata?.uid;
|
||||
if (!uid) throw new Error("Sandbox CR created without a UID");
|
||||
return { uid };
|
||||
}
|
||||
|
||||
export async function getSandboxCrStatus(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
name: string,
|
||||
): Promise<SandboxStatus> {
|
||||
const result = await clients.custom.getNamespacedCustomObject({
|
||||
group: SANDBOX_GROUP,
|
||||
version: SANDBOX_VERSION,
|
||||
namespace,
|
||||
plural: SANDBOX_PLURAL,
|
||||
name,
|
||||
});
|
||||
return mapSandboxPhase(result as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the pod name backing a Sandbox CR.
|
||||
* Primary: read status.podName from the CR (set by the controller once ready).
|
||||
* Fallback: list pods in the namespace filtered by the paperclip.io/managed-by
|
||||
* label and the sandbox name label set on the pod template.
|
||||
*/
|
||||
export async function findPodForSandbox(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
name: string,
|
||||
): Promise<string | null> {
|
||||
// Primary: read status.podName from the Sandbox CR
|
||||
const cr = await clients.custom.getNamespacedCustomObject({
|
||||
group: SANDBOX_GROUP,
|
||||
version: SANDBOX_VERSION,
|
||||
namespace,
|
||||
plural: SANDBOX_PLURAL,
|
||||
name,
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const status = (cr.status as Record<string, unknown>) ?? {};
|
||||
const podName = status.podName as string | undefined;
|
||||
if (podName && podName.trim().length > 0) {
|
||||
return podName;
|
||||
}
|
||||
|
||||
// Fallback: list pods with sandbox-name label (sandbox controller typically
|
||||
// labels pods with the sandbox name)
|
||||
const result = await clients.core.listNamespacedPod({
|
||||
namespace,
|
||||
labelSelector: `paperclip.io/managed-by=paperclip-k8s-plugin`,
|
||||
});
|
||||
const items =
|
||||
(
|
||||
(
|
||||
result as {
|
||||
items?: {
|
||||
metadata?: { name?: string; labels?: Record<string, string> };
|
||||
status?: { phase?: string };
|
||||
}[];
|
||||
}
|
||||
).items
|
||||
) ?? [];
|
||||
|
||||
// Filter to pods that belong to this sandbox by name prefix or label
|
||||
const matching = items.filter((p) => {
|
||||
const podMeta = p.metadata ?? {};
|
||||
const labels = podMeta.labels ?? {};
|
||||
// The sandbox controller may label pods differently; try matching by name prefix
|
||||
return (
|
||||
podMeta.name?.startsWith(name) ||
|
||||
labels["agents.x-k8s.io/sandbox-name"] === name
|
||||
);
|
||||
});
|
||||
|
||||
const running = matching.find((p) => p.status?.phase === "Running");
|
||||
return (running ?? matching[0])?.metadata?.name ?? null;
|
||||
}
|
||||
|
||||
export async function streamSandboxLogs(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
podName: string,
|
||||
onChunk: (stream: "stdout" | "stderr", text: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
// V1 limitation: the Pod log API returns the container's combined log stream. The
|
||||
// sleep-infinity pod will have minimal output; this is provided for interface
|
||||
// conformance. For actual command output, use execInPod() directly.
|
||||
const result = await clients.core.readNamespacedPodLog({
|
||||
namespace,
|
||||
name: podName,
|
||||
});
|
||||
const text =
|
||||
typeof result === "string"
|
||||
? result
|
||||
: typeof (result as { body?: unknown })?.body === "string"
|
||||
? (result as { body: string }).body
|
||||
: "";
|
||||
if (text.length > 0) await onChunk("stdout", text);
|
||||
}
|
||||
|
||||
export async function deleteSandboxCr(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
await clients.custom.deleteNamespacedCustomObject({
|
||||
group: SANDBOX_GROUP,
|
||||
version: SANDBOX_VERSION,
|
||||
namespace,
|
||||
plural: SANDBOX_PLURAL,
|
||||
name,
|
||||
propagationPolicy: "Foreground",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the Sandbox CR's pod reaches Ready phase (i.e., the pod is up and
|
||||
* exec-able). This is NOT waiting for a workload to finish — the Sandbox pod
|
||||
* runs sleep infinity indefinitely. Execution completion is tracked by the
|
||||
* individual execInPod() calls.
|
||||
*
|
||||
* Throws SandboxCrTimeoutError if Ready is not reached within timeoutMs.
|
||||
* Throws if the Sandbox transitions to Failed.
|
||||
*/
|
||||
export async function waitForSandboxReady(
|
||||
clients: KubeClients,
|
||||
namespace: string,
|
||||
name: string,
|
||||
opts: { timeoutMs: number; pollMs?: number } = {
|
||||
timeoutMs: 120_000,
|
||||
pollMs: 2000,
|
||||
},
|
||||
): Promise<SandboxStatus> {
|
||||
const deadline = Date.now() + opts.timeoutMs;
|
||||
const pollMs = opts.pollMs ?? 2000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const cr = await clients.custom.getNamespacedCustomObject({
|
||||
group: SANDBOX_GROUP,
|
||||
version: SANDBOX_VERSION,
|
||||
namespace,
|
||||
plural: SANDBOX_PLURAL,
|
||||
name,
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const status = (cr.status as Record<string, unknown>) ?? {};
|
||||
const phase = (status.phase as string) ?? "Pending";
|
||||
|
||||
if (phase === "Ready") {
|
||||
return mapSandboxPhase(cr);
|
||||
}
|
||||
if (phase === "Failed") {
|
||||
const mapped = mapSandboxPhase(cr);
|
||||
throw new Error(
|
||||
`Sandbox ${namespace}/${name} failed: ${mapped.reason ?? "unknown reason"} — ${mapped.message ?? ""}`,
|
||||
);
|
||||
}
|
||||
if (phase === "Terminating") {
|
||||
throw new Error(`Sandbox ${namespace}/${name} is terminating before it became ready`);
|
||||
}
|
||||
// Pending or unknown — keep polling
|
||||
await sleep(pollMs);
|
||||
}
|
||||
|
||||
throw new SandboxCrTimeoutError(namespace, name, opts.timeoutMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sandbox CR-backed conformance to SandboxOrchestrator.
|
||||
*
|
||||
* waitForCompletion semantics change: for this backend, "completion" means
|
||||
* "pod is up and Ready to exec into" — NOT "workload finished". The actual
|
||||
* command execution and its completion is handled by execInPod().
|
||||
*/
|
||||
export const sandboxCrOrchestrator: SandboxOrchestrator = {
|
||||
claim: createSandboxCr,
|
||||
getStatus: getSandboxCrStatus,
|
||||
findPod: findPodForSandbox,
|
||||
streamLogs: streamSandboxLogs,
|
||||
release: deleteSandboxCr,
|
||||
waitForCompletion: waitForSandboxReady,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user