563413ecd4
## Thinking Path > - Paperclip is the control plane for autonomous AI companies, and plugins extend that control plane without bloating core. > - The LLM Wiki plugin adds a knowledge surface through the plugin runtime and shared plugin UI components. > - After the LLM Wiki work merged to `master`, CI exposed TypeScript contract drift between plugin code, SDK component types, and update settings types. > - The ingestion settings update path intentionally accepts partial source toggles, but its type intersected with the full settings shape and required every source key. > - The LLM Wiki UI also passes managed routine default-drift metadata through the shared routine list item shape, but that metadata was missing from the public item type. > - This pull request narrows those type contracts to match the existing runtime behavior. > - The benefit is restoring typecheck on `master` with a small, non-behavioral follow-up. ## What Changed - Added a `WikiEventIngestionSettingsUpdate` type that permits partial source updates without weakening normalized stored settings. - Added managed routine default-drift metadata to the plugin SDK `ManagedRoutinesListItem` type. - Mirrored that managed routine default-drift type in the host UI component item type. ## Verification - `pnpm --filter @paperclipai/plugin-llm-wiki typecheck` - `pnpm --filter @paperclipai/plugin-sdk typecheck` - `pnpm --filter @paperclipai/ui typecheck` - `git diff --check` ## Risks - Low risk. This is a TypeScript type-contract fix only; no runtime behavior or database schema changes. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5-based coding agent, tool-enabled local repository editing and command execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge Notes on checklist applicability: no screenshots are included because the UI change is a shared type-only contract update with no visual behavior change; no docs were required because no behavior or commands changed. Co-authored-by: Paperclip <noreply@paperclip.ing>
4981 lines
191 KiB
TypeScript
4981 lines
191 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import type { Agent, AgentSessionEvent, Issue, IssueComment, PluginContext, PluginEvent, PluginLocalFolderEntry, Project, ToolResult } from "@paperclipai/plugin-sdk";
|
|
import type { IssueDocument, PluginIssueOriginKind, PluginManagedRoutineResolution, PluginManagedSkillResolution } from "@paperclipai/plugin-sdk/types";
|
|
import {
|
|
DEFAULT_MAX_SOURCE_BYTES,
|
|
DEFAULT_MAX_PAPERCLIP_CURSOR_WINDOW_CHARS,
|
|
DEFAULT_MAX_PAPERCLIP_ISSUE_SOURCE_CHARS,
|
|
DEFAULT_MAX_PAPERCLIP_ROUTINE_RUN_CHARS,
|
|
DEFAULT_PAPERCLIP_COST_CENTS_PER_1K_CHARS,
|
|
PLUGIN_ID,
|
|
WIKI_MAINTAINER_AGENT_KEY,
|
|
WIKI_MANAGED_SKILL_KEYS,
|
|
WIKI_MAINTENANCE_ROUTINE_KEYS,
|
|
WIKI_PROJECT_KEY,
|
|
WIKI_ROOT_FOLDER_KEY,
|
|
} from "../manifest.js";
|
|
import {
|
|
BOOTSTRAP_FILES,
|
|
LINT_PROMPT,
|
|
QUERY_PROMPT,
|
|
REQUIRED_WIKI_DIRECTORIES,
|
|
REQUIRED_WIKI_FILES,
|
|
} from "../templates.js";
|
|
|
|
export const DEFAULT_WIKI_ID = "default";
|
|
export const DEFAULT_SPACE_SLUG = "default";
|
|
export const OPERATION_ORIGIN_KIND = `plugin:${PLUGIN_ID}:operation` as PluginIssueOriginKind;
|
|
const EVENT_INGESTION_STATE_NAMESPACE = "llm-wiki";
|
|
const EVENT_INGESTION_STATE_KEY = "event-ingestion";
|
|
const EVENT_INGESTION_DEDUP_NAMESPACE = "llm-wiki-event-ingestion";
|
|
const MAX_EVENT_SOURCE_CHARS = 20000;
|
|
const MAX_PAPERCLIP_INGESTION_PROFILE_SOURCE_COUNT = 3;
|
|
const MAX_PAPERCLIP_DISTILLATION_FAN_OUT = 25;
|
|
const MAX_PAPERCLIP_PROFILE_SELECTED_PROJECTS = 25;
|
|
const MAX_PAPERCLIP_PROFILE_ROOT_ISSUES = 25;
|
|
const PROTECTED_WIKI_CONTROL_FILES = new Set(["AGENTS.md", "IDEA.md"]);
|
|
export const PUBLIC_DISTILLATION_AUTO_APPLY_RESTRICTION =
|
|
"Authenticated/public deployments always require manual review before wiki writes.";
|
|
|
|
export type WikiEventIngestionSource = "issues" | "comments" | "documents";
|
|
export type PaperclipDistillationScope = "company" | "project" | "root_issue";
|
|
export type PaperclipDistillationWorkItemKind = "manual" | "retry" | "backfill" | "priority_override" | "review_patch";
|
|
|
|
export type WikiEventIngestionSettings = {
|
|
enabled: boolean;
|
|
sources: Record<WikiEventIngestionSource, boolean>;
|
|
wikiId: string;
|
|
maxCharacters: number;
|
|
};
|
|
|
|
export type WikiEventIngestionSettingsUpdate = Omit<Partial<WikiEventIngestionSettings>, "sources"> & {
|
|
sources?: Partial<Record<WikiEventIngestionSource, boolean>>;
|
|
};
|
|
|
|
export type PaperclipIngestionSourceScope =
|
|
| { kind: "active_projects"; limit: number; statuses?: Array<"in_progress" | "todo" | "done"> }
|
|
| { kind: "selected_projects"; projectIds: string[] }
|
|
| { kind: "root_issues"; issueIds: string[] }
|
|
| { kind: "company_all"; requiresBoardConfirmation: true };
|
|
|
|
export type PaperclipIngestionProfileV1 = {
|
|
version: 1;
|
|
enabled: boolean;
|
|
sourceScopes: PaperclipIngestionSourceScope[];
|
|
sourceKinds: Record<WikiEventIngestionSource, boolean> & {
|
|
attachments: "off" | "metadata_only";
|
|
workProducts: "off" | "metadata_only";
|
|
};
|
|
cursor: {
|
|
maxWindowCharacters: number;
|
|
maxCharactersPerSource: number;
|
|
minSourceAgeMinutes: number;
|
|
maxWindowsPerRun: number;
|
|
staleAfterHours: number;
|
|
};
|
|
backfill: {
|
|
defaultStartAt?: string | null;
|
|
defaultEndAt?: string | null;
|
|
requireManualQueue: boolean;
|
|
};
|
|
};
|
|
|
|
export type PaperclipIngestionProfileEffectiveState =
|
|
| "enabled"
|
|
| "disabled"
|
|
| "policy_blocked"
|
|
| "pending_approval"
|
|
| "enabled_no_scopes";
|
|
|
|
export type PaperclipIngestionProfileRead = {
|
|
wikiId: string;
|
|
space: Pick<WikiSpace, "id" | "slug" | "displayName" | "accessScope" | "status">;
|
|
profile: PaperclipIngestionProfileV1;
|
|
effectiveState: PaperclipIngestionProfileEffectiveState;
|
|
policyBlocks: string[];
|
|
historicalPageCount: number;
|
|
overlapCount: number;
|
|
};
|
|
|
|
export type DistillationAutoApplyRestriction = {
|
|
autoApplyAllowed: boolean;
|
|
autoApplyRestriction: string | null;
|
|
deploymentMode: "local_trusted" | "authenticated" | null;
|
|
deploymentExposure: "private" | "public" | null;
|
|
};
|
|
|
|
type PaperclipIngestionPolicyPurpose =
|
|
| "profile_read"
|
|
| "profile_update"
|
|
| "candidate_search"
|
|
| "queue"
|
|
| "execute"
|
|
| "event_routing";
|
|
|
|
type PaperclipIngestionPolicyDecision =
|
|
| { allowed: true; space: WikiSpace }
|
|
| { allowed: false; space: WikiSpace; reason: "restricted_space" | "archived_space" | "profile_disabled" | "profile_empty"; message: string };
|
|
|
|
type PaperclipIngestionCandidatesInput = SpaceInput & {
|
|
query?: string | null;
|
|
};
|
|
|
|
export type EnableActiveProjectDistillationResult = {
|
|
wikiId: string;
|
|
spaceSlug: string;
|
|
selectedProjects: Array<{
|
|
id: string;
|
|
name: string;
|
|
status: string;
|
|
observedAt: string | null;
|
|
cursorId: string;
|
|
}>;
|
|
eventIngestion: WikiEventIngestionSettings;
|
|
};
|
|
|
|
export const DEFAULT_EVENT_INGESTION_SETTINGS: WikiEventIngestionSettings = {
|
|
enabled: false,
|
|
sources: {
|
|
issues: false,
|
|
comments: false,
|
|
documents: false,
|
|
},
|
|
wikiId: DEFAULT_WIKI_ID,
|
|
maxCharacters: 12000,
|
|
};
|
|
|
|
export type WikiOverview = {
|
|
status: "ok";
|
|
checkedAt: string;
|
|
wikiId: string;
|
|
defaultSpace: WikiSpace;
|
|
folder: Awaited<ReturnType<PluginContext["localFolders"]["status"]>>;
|
|
managedAgent: WikiAgentResource;
|
|
managedProject: WikiProjectResource;
|
|
managedSkills: WikiSkillResource[];
|
|
operationCount: number;
|
|
eventIngestion: WikiEventIngestionSettings;
|
|
capabilities: readonly string[];
|
|
prompts: {
|
|
query: string;
|
|
lint: string;
|
|
};
|
|
};
|
|
|
|
export type WikiAgentResource = {
|
|
status: "missing" | "resolved" | "created" | "relinked" | "reset";
|
|
source: "managed" | "selected";
|
|
agentId: string | null;
|
|
resourceKey: string;
|
|
agent: Agent | null;
|
|
details: { name: string; status: string; adapterType: string | null; icon?: string | null; urlKey?: string | null } | null;
|
|
defaultDrift?: { entryFile: string; changedFiles: string[] } | null;
|
|
};
|
|
|
|
export type WikiProjectResource = {
|
|
status: "missing" | "resolved" | "created" | "relinked" | "reset";
|
|
source: "managed" | "selected";
|
|
projectId: string | null;
|
|
resourceKey: string;
|
|
project: Project | null;
|
|
details: { name: string; status: string; color: string | null } | null;
|
|
};
|
|
|
|
export type WikiSkillResource = {
|
|
status: "missing" | "resolved" | "created" | "relinked" | "reset";
|
|
skillId: string | null;
|
|
resourceKey: string;
|
|
skill: PluginManagedSkillResolution["skill"];
|
|
details: { name: string; key: string; description: string | null } | null;
|
|
defaultDrift?: { changedFiles: string[] } | null;
|
|
};
|
|
|
|
export type WikiResourceOption = {
|
|
id: string;
|
|
name: string;
|
|
status?: string | null;
|
|
adapterType?: string | null;
|
|
color?: string | null;
|
|
icon?: string | null;
|
|
urlKey?: string | null;
|
|
};
|
|
|
|
export type WikiSpace = {
|
|
id: string;
|
|
companyId: string;
|
|
wikiId: string;
|
|
slug: string;
|
|
displayName: string;
|
|
spaceType: string;
|
|
folderMode: string;
|
|
rootFolderKey: string;
|
|
pathPrefix: string | null;
|
|
configuredRootPath: string | null;
|
|
accessScope: string;
|
|
ownerUserId: string | null;
|
|
ownerAgentId: string | null;
|
|
teamKey: string | null;
|
|
settings: Record<string, unknown>;
|
|
status: "active" | "archived" | string;
|
|
createdAt: string | null;
|
|
updatedAt: string | null;
|
|
};
|
|
|
|
export type WikiSpaceWithFolderStatus = WikiSpace & {
|
|
relativeRoot: string;
|
|
folder: Awaited<ReturnType<PluginContext["localFolders"]["status"]>>;
|
|
};
|
|
|
|
type BootstrapInput = {
|
|
companyId: string;
|
|
path?: string | null;
|
|
};
|
|
|
|
type SpaceInput = {
|
|
companyId: string;
|
|
wikiId?: string | null;
|
|
spaceSlug?: string | null;
|
|
};
|
|
|
|
type CreateSpaceInput = {
|
|
companyId: string;
|
|
wikiId?: string | null;
|
|
slug?: string | null;
|
|
displayName?: string | null;
|
|
folderMode?: "managed_subfolder" | "existing_local_folder" | null;
|
|
accessScope?: "shared" | "personal" | "team" | null;
|
|
settings?: Record<string, unknown> | null;
|
|
};
|
|
|
|
type UpdateSpaceInput = SpaceInput & {
|
|
displayName?: string | null;
|
|
settings?: Record<string, unknown> | null;
|
|
status?: "active" | "archived" | null;
|
|
};
|
|
|
|
type OperationInput = {
|
|
companyId: string;
|
|
wikiId?: string | null;
|
|
spaceSlug?: string | null;
|
|
operationType: "ingest" | "query" | "lint" | "file-as-page" | "index" | "distill" | "backfill";
|
|
title?: string | null;
|
|
prompt?: string | null;
|
|
useCheapModelProfile?: boolean;
|
|
};
|
|
|
|
type OperationSpaceContext = {
|
|
wikiId: string;
|
|
space: WikiSpace;
|
|
operationType: OperationInput["operationType"];
|
|
operationId: string;
|
|
prompt?: string | null;
|
|
};
|
|
|
|
type QuerySessionInput = {
|
|
companyId: string;
|
|
wikiId?: string | null;
|
|
spaceSlug?: string | null;
|
|
question: string;
|
|
title?: string | null;
|
|
};
|
|
|
|
type CaptureSourceInput = {
|
|
companyId: string;
|
|
wikiId?: string | null;
|
|
spaceSlug?: string | null;
|
|
sourceType?: string | null;
|
|
title?: string | null;
|
|
url?: string | null;
|
|
contents: string;
|
|
rawPath?: string | null;
|
|
metadata?: Record<string, unknown> | null;
|
|
};
|
|
|
|
type PaperclipSourceBundleInput = {
|
|
companyId: string;
|
|
wikiId?: string | null;
|
|
spaceSlug?: string | null;
|
|
projectId?: string | null;
|
|
rootIssueId?: string | null;
|
|
maxCharacters?: number | null;
|
|
maxCharactersPerSource?: number | null;
|
|
backfillStartAt?: string | null;
|
|
backfillEndAt?: string | null;
|
|
routineRun?: boolean;
|
|
includeComments?: boolean;
|
|
includeDocuments?: boolean;
|
|
workItemId?: string | null;
|
|
operationIssueId?: string | null;
|
|
};
|
|
|
|
type PaperclipSourceRef = {
|
|
kind: "issue" | "comment" | "document";
|
|
issueId: string;
|
|
issueIdentifier: string | null;
|
|
projectId: string | null;
|
|
title?: string | null;
|
|
commentId?: string;
|
|
documentId?: string;
|
|
documentKey?: string;
|
|
updatedAt?: string;
|
|
createdAt?: string;
|
|
redactionStatus?: "suppressed_sensitive_content";
|
|
redactionReasons?: string[];
|
|
};
|
|
|
|
type PaperclipSourceBundle = {
|
|
markdown: string;
|
|
sourceRefs: PaperclipSourceRef[];
|
|
sourceHash: string;
|
|
sourceWindowStart: string | null;
|
|
sourceWindowEnd: string | null;
|
|
clipped: boolean;
|
|
warnings: string[];
|
|
};
|
|
|
|
type PaperclipDistillationRunInput = PaperclipSourceBundleInput;
|
|
|
|
type PaperclipDistillationOutcomeInput = {
|
|
companyId: string;
|
|
wikiId?: string | null;
|
|
spaceSlug?: string | null;
|
|
runId: string;
|
|
cursorId?: string | null;
|
|
status: "succeeded" | "failed" | "review_required";
|
|
sourceHash?: string | null;
|
|
sourceWindowEnd?: string | null;
|
|
warning?: string | null;
|
|
costCents?: number | null;
|
|
retryCount?: number | null;
|
|
};
|
|
|
|
type PaperclipDistillationWorkItemInput = {
|
|
companyId: string;
|
|
wikiId?: string | null;
|
|
spaceSlug?: string | null;
|
|
kind: PaperclipDistillationWorkItemKind;
|
|
projectId?: string | null;
|
|
rootIssueId?: string | null;
|
|
requestedByIssueId?: string | null;
|
|
priority?: "critical" | "high" | "medium" | "low" | null;
|
|
idempotencyKey?: string | null;
|
|
metadata?: Record<string, unknown> | null;
|
|
};
|
|
|
|
type PaperclipProjectPageDistillationInput = PaperclipSourceBundleInput & {
|
|
autoApply?: boolean;
|
|
expectedProjectPageHash?: string | null;
|
|
includeSupportingPages?: boolean;
|
|
};
|
|
|
|
type WritePageInput = {
|
|
companyId: string;
|
|
wikiId?: string | null;
|
|
spaceSlug?: string | null;
|
|
path: string;
|
|
contents: string;
|
|
expectedHash?: string | null;
|
|
summary?: string | null;
|
|
sourceRefs?: unknown;
|
|
operationId?: string | null;
|
|
writer?: "agent_tool" | "board_ui" | "plugin_internal";
|
|
};
|
|
|
|
type FileQueryAnswerInput = {
|
|
companyId: string;
|
|
wikiId?: string | null;
|
|
spaceSlug?: string | null;
|
|
querySessionId?: string | null;
|
|
question?: string | null;
|
|
answer?: string | null;
|
|
path: string;
|
|
title?: string | null;
|
|
contents?: string | null;
|
|
expectedHash?: string | null;
|
|
};
|
|
|
|
type ToolParams = Record<string, unknown>;
|
|
type WikiResourceKind = "agent" | "project";
|
|
type PaperclipDistillationPatchOperation =
|
|
| "standup_update"
|
|
| "project_page_distill"
|
|
| "decision_distill"
|
|
| "history_distill"
|
|
| "index_refresh"
|
|
| "log_append";
|
|
type PaperclipDistillationPatch = {
|
|
pagePath: string;
|
|
operationType: PaperclipDistillationPatchOperation;
|
|
currentHash: string | null;
|
|
proposedHash: string;
|
|
proposedContents: string;
|
|
sourceHash: string;
|
|
sourceRefs: PaperclipSourceRef[];
|
|
cursorWindow: {
|
|
start: string | null;
|
|
end: string | null;
|
|
};
|
|
confidence: "high" | "medium" | "low";
|
|
warnings: string[];
|
|
humanReviewRequired: boolean;
|
|
};
|
|
type PaperclipEventIngestResult =
|
|
| { status: "skipped"; reason: "disabled" | "source_disabled" | "unsupported_event" | "missing_issue" | "missing_comment" | "missing_document" | "plugin_operation" | "already_ingested" }
|
|
| { status: "recorded"; sourceKind: WikiEventIngestionSource; sourceId: string; cursorId: string; issueId: string };
|
|
|
|
type WikiResourceBinding = {
|
|
resolvedId: string | null;
|
|
metadata: Record<string, unknown>;
|
|
};
|
|
|
|
function stringField(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
}
|
|
|
|
function requireString(value: unknown, name: string): string {
|
|
const field = stringField(value);
|
|
if (!field) throw new Error(`${name} is required`);
|
|
return field;
|
|
}
|
|
|
|
function normalizeWikiId(value: unknown): string {
|
|
return stringField(value) ?? DEFAULT_WIKI_ID;
|
|
}
|
|
|
|
export function normalizeSpaceSlug(value: unknown): string {
|
|
const raw = stringField(value) ?? DEFAULT_SPACE_SLUG;
|
|
const normalized = raw
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
if (!normalized) throw new Error("spaceSlug is required");
|
|
if (normalized.length > 64) throw new Error("spaceSlug must be 64 characters or fewer");
|
|
return normalized;
|
|
}
|
|
|
|
async function requirePaperclipIngestionPolicy(
|
|
ctx: PluginContext,
|
|
input: { companyId: string; wikiId: string; spaceSlug?: string | null },
|
|
purpose: PaperclipIngestionPolicyPurpose,
|
|
options: { requireEnabledProfile?: boolean } = {},
|
|
): Promise<WikiSpace> {
|
|
const space = await resolveSpace(ctx, {
|
|
companyId: input.companyId,
|
|
wikiId: input.wikiId,
|
|
spaceSlug: input.spaceSlug,
|
|
});
|
|
const profile = await profileForSpace(ctx, input.companyId, space);
|
|
const decision = evaluatePaperclipProfilePolicy({
|
|
space,
|
|
profile,
|
|
purpose,
|
|
requireEnabledProfile: options.requireEnabledProfile,
|
|
});
|
|
if (!decision.allowed) throw new Error(decision.message);
|
|
return decision.space;
|
|
}
|
|
|
|
function assertPaperclipSourceScopePayload(input: { projectId?: string | null; rootIssueId?: string | null }) {
|
|
if (input.projectId && input.rootIssueId) {
|
|
throw new Error("Paperclip source scope must specify either projectId or rootIssueId, not both.");
|
|
}
|
|
}
|
|
|
|
function assertRequestedCharacterLimit(name: string, value: unknown, max: number) {
|
|
if (value == null) return;
|
|
if (typeof value !== "number" || !Number.isFinite(value) || value < 1) {
|
|
throw new Error(`${name} must be a positive number.`);
|
|
}
|
|
if (Math.floor(value) > max) {
|
|
throw new Error(`${name} exceeds the hard Paperclip ingestion cap of ${max} characters.`);
|
|
}
|
|
}
|
|
|
|
function stableSpaceId(input: { companyId: string; wikiId: string; slug: string }): string {
|
|
const hex = createHash("md5")
|
|
.update(`${input.companyId}:${input.wikiId}:${input.slug}`)
|
|
.digest("hex");
|
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
}
|
|
|
|
function normalizeLimit(value: unknown, fallback: number, max: number): number {
|
|
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
return Math.max(1, Math.min(max, Math.floor(value)));
|
|
}
|
|
|
|
function contentHash(contents: string): string {
|
|
return createHash("sha256").update(contents, "utf8").digest("hex");
|
|
}
|
|
|
|
function byteLength(contents: string): number {
|
|
return Buffer.byteLength(contents, "utf8");
|
|
}
|
|
|
|
function slugify(value: string): string {
|
|
const slug = value
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
return slug || "source";
|
|
}
|
|
|
|
function jsonParam(value: unknown): string {
|
|
return JSON.stringify(value ?? {});
|
|
}
|
|
|
|
function jsonArrayParam(value: unknown): string {
|
|
return JSON.stringify(Array.isArray(value) ? value : []);
|
|
}
|
|
|
|
function isoString(value: unknown): string | null {
|
|
if (value instanceof Date) return value.toISOString();
|
|
if (typeof value === "string" && value.trim().length > 0) {
|
|
const parsed = new Date(value);
|
|
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function normalizeBoolean(value: unknown, fallback: boolean): boolean {
|
|
return typeof value === "boolean" ? value : fallback;
|
|
}
|
|
|
|
function normalizeMaxSourceBytes(value: unknown): number {
|
|
if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_MAX_SOURCE_BYTES;
|
|
return Math.max(1, Math.floor(value));
|
|
}
|
|
|
|
function normalizeBundleLimit(value: unknown, fallback: number): number {
|
|
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
return Math.max(1000, Math.floor(value));
|
|
}
|
|
|
|
function normalizeCostRate(value: unknown): number {
|
|
if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_PAPERCLIP_COST_CENTS_PER_1K_CHARS;
|
|
return Math.max(0, value);
|
|
}
|
|
|
|
type PaperclipDistillationLimits = {
|
|
maxCharacters: number;
|
|
maxCharactersPerSource: number;
|
|
maxRoutineRunCharacters: number;
|
|
costCentsPerThousandSourceCharacters: number;
|
|
};
|
|
|
|
const DISTILLATION_REDACTED_VALUE = "***REDACTED***";
|
|
const DISTILLATION_JSON_SECRET_FIELD_TEXT_RE =
|
|
/((?:"|')?(?:api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)(?:"|')?\s*:\s*(?:"|'))[^"'`\r\n]+((?:"|'))/gi;
|
|
const DISTILLATION_ESCAPED_JSON_SECRET_FIELD_TEXT_RE =
|
|
/((?:\\")?(?:api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)(?:\\")?\s*:\s*(?:\\"))[^\\\r\n]+((?:\\"))/gi;
|
|
const DISTILLATION_ENV_SECRET_ASSIGNMENT_RE =
|
|
/(\b[A-Za-z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|AUTHORIZATION|JWT)[A-Za-z0-9_]*\s*=\s*)[^\s"'`]+/gi;
|
|
const DISTILLATION_AUTHORIZATION_BEARER_RE = /(\bAuthorization\s*:\s*Bearer\s+)[^\s"'`]+/gi;
|
|
const DISTILLATION_OPENAI_KEY_RE = /\bsk-[A-Za-z0-9_-]{12,}\b/g;
|
|
const DISTILLATION_GITHUB_TOKEN_RE = /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g;
|
|
const DISTILLATION_JWT_RE =
|
|
/\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(?:\.[A-Za-z0-9_-]{8,})?\b/g;
|
|
const DISTILLATION_CONNECTION_STRING_RE =
|
|
/\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqps?):\/\/[^\s"'`]+/gi;
|
|
const DISTILLATION_PRIVATE_KEY_BLOCK_RE =
|
|
/-----BEGIN(?:[A-Z ]+)?PRIVATE KEY-----[\s\S]*?-----END(?:[A-Z ]+)?PRIVATE KEY-----/gi;
|
|
const DISTILLATION_PRIVATE_KEY_BLOCK_TEST_RE =
|
|
/-----BEGIN(?:[A-Z ]+)?PRIVATE KEY-----[\s\S]*?-----END(?:[A-Z ]+)?PRIVATE KEY-----/i;
|
|
|
|
type DistillationSourceProtectionResult = {
|
|
body: string;
|
|
warning: string | null;
|
|
refPatch: Pick<PaperclipSourceRef, "redactionStatus" | "redactionReasons">;
|
|
};
|
|
|
|
function redactDistillationSensitiveText(input: string): string {
|
|
return input
|
|
.replace(DISTILLATION_PRIVATE_KEY_BLOCK_RE, DISTILLATION_REDACTED_VALUE)
|
|
.replace(DISTILLATION_JSON_SECRET_FIELD_TEXT_RE, `$1${DISTILLATION_REDACTED_VALUE}$2`)
|
|
.replace(DISTILLATION_ESCAPED_JSON_SECRET_FIELD_TEXT_RE, `$1${DISTILLATION_REDACTED_VALUE}$2`)
|
|
.replace(DISTILLATION_ENV_SECRET_ASSIGNMENT_RE, `$1${DISTILLATION_REDACTED_VALUE}`)
|
|
.replace(DISTILLATION_AUTHORIZATION_BEARER_RE, `$1${DISTILLATION_REDACTED_VALUE}`)
|
|
.replace(DISTILLATION_CONNECTION_STRING_RE, DISTILLATION_REDACTED_VALUE)
|
|
.replace(DISTILLATION_OPENAI_KEY_RE, DISTILLATION_REDACTED_VALUE)
|
|
.replace(DISTILLATION_GITHUB_TOKEN_RE, DISTILLATION_REDACTED_VALUE)
|
|
.replace(DISTILLATION_JWT_RE, DISTILLATION_REDACTED_VALUE);
|
|
}
|
|
|
|
function protectDistillationSourceBody(input: {
|
|
issue: Issue;
|
|
sourceKind: "comment" | "document";
|
|
sourceId: string;
|
|
body: string;
|
|
}): DistillationSourceProtectionResult {
|
|
const redactedBody = redactDistillationSensitiveText(input.body);
|
|
const reasons = [
|
|
DISTILLATION_PRIVATE_KEY_BLOCK_TEST_RE.test(input.body) ? "private_key_block" : null,
|
|
redactedBody !== input.body ? "secret_like_token" : null,
|
|
].filter((reason): reason is string => Boolean(reason));
|
|
if (reasons.length === 0) {
|
|
return {
|
|
body: input.body,
|
|
warning: null,
|
|
refPatch: {},
|
|
};
|
|
}
|
|
|
|
return {
|
|
body: [
|
|
`[Suppressed by LLM Wiki distillation security policy for this ${input.sourceKind}.]`,
|
|
"",
|
|
`- Source ID: ${input.sourceId}`,
|
|
`- Redaction reasons: ${reasons.join(", ")}`,
|
|
"- Review the original Paperclip source directly if a human needs the unredacted material.",
|
|
].join("\n"),
|
|
warning: `Suppressed ${input.sourceKind} content for ${sourceTitleForIssue(input.issue)} / ${input.sourceId}: ${reasons.join(", ")}.`,
|
|
refPatch: {
|
|
redactionStatus: "suppressed_sensitive_content",
|
|
redactionReasons: reasons,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function resolvePaperclipDistillationLimits(
|
|
ctx: PluginContext,
|
|
input: Pick<PaperclipSourceBundleInput, "maxCharacters" | "maxCharactersPerSource" | "routineRun">,
|
|
): Promise<PaperclipDistillationLimits> {
|
|
assertRequestedCharacterLimit("maxCharacters", input.maxCharacters, DEFAULT_MAX_PAPERCLIP_CURSOR_WINDOW_CHARS);
|
|
assertRequestedCharacterLimit("maxCharactersPerSource", input.maxCharactersPerSource, DEFAULT_MAX_PAPERCLIP_ISSUE_SOURCE_CHARS);
|
|
const config = await ctx.config.get() as Record<string, unknown>;
|
|
const maxCharactersPerSource = Math.min(
|
|
normalizeBundleLimit(input.maxCharactersPerSource, DEFAULT_MAX_PAPERCLIP_ISSUE_SOURCE_CHARS),
|
|
normalizeBundleLimit(config.maxPaperclipIssueSourceCharacters, DEFAULT_MAX_PAPERCLIP_ISSUE_SOURCE_CHARS),
|
|
);
|
|
const cursorWindowCap = normalizeBundleLimit(
|
|
config.maxPaperclipCursorWindowCharacters,
|
|
DEFAULT_MAX_PAPERCLIP_CURSOR_WINDOW_CHARS,
|
|
);
|
|
const routineRunCap = normalizeBundleLimit(
|
|
config.maxPaperclipRoutineRunCharacters,
|
|
DEFAULT_MAX_PAPERCLIP_ROUTINE_RUN_CHARS,
|
|
);
|
|
const requestedMaxCharacters = normalizeBundleLimit(input.maxCharacters, cursorWindowCap);
|
|
const hardCharacterCap = input.routineRun ? Math.min(cursorWindowCap, routineRunCap) : cursorWindowCap;
|
|
return {
|
|
maxCharacters: Math.min(requestedMaxCharacters, hardCharacterCap),
|
|
maxCharactersPerSource,
|
|
maxRoutineRunCharacters: routineRunCap,
|
|
costCentsPerThousandSourceCharacters: normalizeCostRate(config.paperclipCostCentsPerThousandSourceCharacters),
|
|
};
|
|
}
|
|
|
|
async function resolvePaperclipDistillationLimitsForSpace(
|
|
ctx: PluginContext,
|
|
input: Pick<PaperclipSourceBundleInput, "companyId" | "maxCharacters" | "maxCharactersPerSource" | "routineRun"> & { space: WikiSpace },
|
|
): Promise<PaperclipDistillationLimits> {
|
|
const [base, profile] = await Promise.all([
|
|
resolvePaperclipDistillationLimits(ctx, input),
|
|
profileForSpace(ctx, input.companyId, input.space),
|
|
]);
|
|
return {
|
|
...base,
|
|
maxCharacters: Math.min(base.maxCharacters, profile.cursor.maxWindowCharacters),
|
|
maxCharactersPerSource: Math.min(base.maxCharactersPerSource, profile.cursor.maxCharactersPerSource),
|
|
};
|
|
}
|
|
|
|
function estimateSourceCostCents(characters: number, costCentsPerThousandSourceCharacters: number): number {
|
|
if (characters <= 0 || costCentsPerThousandSourceCharacters <= 0) return 0;
|
|
return Math.ceil((characters / 1000) * costCentsPerThousandSourceCharacters);
|
|
}
|
|
|
|
async function assertSourceWithinConfiguredLimit(ctx: PluginContext, contents: string) {
|
|
const config = await ctx.config.get();
|
|
const maxSourceBytes = normalizeMaxSourceBytes(config.maxSourceBytes);
|
|
const sourceBytes = byteLength(contents);
|
|
if (sourceBytes > maxSourceBytes) {
|
|
throw new Error(`Source content is ${sourceBytes} bytes, which exceeds the configured LLM Wiki source limit of ${maxSourceBytes} bytes.`);
|
|
}
|
|
}
|
|
|
|
function normalizeEventIngestionSettings(value: unknown): WikiEventIngestionSettings {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return { ...DEFAULT_EVENT_INGESTION_SETTINGS, sources: { ...DEFAULT_EVENT_INGESTION_SETTINGS.sources } };
|
|
}
|
|
const record = value as Record<string, unknown>;
|
|
const sources = record.sources && typeof record.sources === "object" && !Array.isArray(record.sources)
|
|
? record.sources as Record<string, unknown>
|
|
: {};
|
|
const maxCharacters = typeof record.maxCharacters === "number" && Number.isFinite(record.maxCharacters)
|
|
? Math.max(1000, Math.min(MAX_EVENT_SOURCE_CHARS, Math.floor(record.maxCharacters)))
|
|
: DEFAULT_EVENT_INGESTION_SETTINGS.maxCharacters;
|
|
return {
|
|
enabled: normalizeBoolean(record.enabled, DEFAULT_EVENT_INGESTION_SETTINGS.enabled),
|
|
sources: {
|
|
issues: normalizeBoolean(sources.issues, DEFAULT_EVENT_INGESTION_SETTINGS.sources.issues),
|
|
comments: normalizeBoolean(sources.comments, DEFAULT_EVENT_INGESTION_SETTINGS.sources.comments),
|
|
documents: normalizeBoolean(sources.documents, DEFAULT_EVENT_INGESTION_SETTINGS.sources.documents),
|
|
},
|
|
wikiId: normalizeWikiId(record.wikiId),
|
|
maxCharacters,
|
|
};
|
|
}
|
|
|
|
function defaultPaperclipIngestionProfile(input: {
|
|
space: Pick<WikiSpace, "slug">;
|
|
legacySettings?: WikiEventIngestionSettings | null;
|
|
}): PaperclipIngestionProfileV1 {
|
|
const legacy = input.space.slug === DEFAULT_SPACE_SLUG ? input.legacySettings : null;
|
|
return {
|
|
version: 1,
|
|
enabled: legacy?.enabled ?? false,
|
|
sourceScopes: legacy?.enabled ? [{ kind: "company_all", requiresBoardConfirmation: true }] : [],
|
|
sourceKinds: {
|
|
issues: legacy?.sources.issues ?? true,
|
|
comments: legacy?.sources.comments ?? true,
|
|
documents: legacy?.sources.documents ?? true,
|
|
attachments: "off",
|
|
workProducts: "off",
|
|
},
|
|
cursor: {
|
|
maxWindowCharacters: DEFAULT_MAX_PAPERCLIP_CURSOR_WINDOW_CHARS,
|
|
maxCharactersPerSource: DEFAULT_MAX_PAPERCLIP_ISSUE_SOURCE_CHARS,
|
|
minSourceAgeMinutes: 15,
|
|
maxWindowsPerRun: 6,
|
|
staleAfterHours: 72,
|
|
},
|
|
backfill: {
|
|
defaultStartAt: null,
|
|
defaultEndAt: null,
|
|
requireManualQueue: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
function stringArray(value: unknown): string[] {
|
|
if (!Array.isArray(value)) return [];
|
|
return [...new Set(value.map((item) => stringField(item)).filter((item): item is string => Boolean(item)))];
|
|
}
|
|
|
|
function normalizePaperclipIngestionSourceScope(value: unknown): PaperclipIngestionSourceScope | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
const record = value as Record<string, unknown>;
|
|
const kind = stringField(record.kind);
|
|
if (kind === "active_projects") {
|
|
const statuses = Array.isArray(record.statuses)
|
|
? record.statuses.filter((status): status is "in_progress" | "todo" | "done" =>
|
|
status === "in_progress" || status === "todo" || status === "done")
|
|
: undefined;
|
|
return {
|
|
kind,
|
|
limit: normalizeLimit(record.limit, 3, MAX_PAPERCLIP_PROFILE_SELECTED_PROJECTS),
|
|
...(statuses && statuses.length > 0 ? { statuses: [...new Set(statuses)] } : {}),
|
|
};
|
|
}
|
|
if (kind === "selected_projects") {
|
|
return { kind, projectIds: stringArray(record.projectIds).slice(0, MAX_PAPERCLIP_PROFILE_SELECTED_PROJECTS) };
|
|
}
|
|
if (kind === "root_issues") {
|
|
return { kind, issueIds: stringArray(record.issueIds).slice(0, MAX_PAPERCLIP_PROFILE_ROOT_ISSUES) };
|
|
}
|
|
if (kind === "company_all") {
|
|
return { kind, requiresBoardConfirmation: true };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function normalizePaperclipIngestionProfile(
|
|
value: unknown,
|
|
input: { space: Pick<WikiSpace, "slug">; legacySettings?: WikiEventIngestionSettings | null },
|
|
): PaperclipIngestionProfileV1 {
|
|
const fallback = defaultPaperclipIngestionProfile(input);
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return fallback;
|
|
const record = value as Record<string, unknown>;
|
|
const sourceKinds = record.sourceKinds && typeof record.sourceKinds === "object" && !Array.isArray(record.sourceKinds)
|
|
? record.sourceKinds as Record<string, unknown>
|
|
: {};
|
|
const cursor = record.cursor && typeof record.cursor === "object" && !Array.isArray(record.cursor)
|
|
? record.cursor as Record<string, unknown>
|
|
: {};
|
|
const backfill = record.backfill && typeof record.backfill === "object" && !Array.isArray(record.backfill)
|
|
? record.backfill as Record<string, unknown>
|
|
: {};
|
|
return {
|
|
version: 1,
|
|
enabled: normalizeBoolean(record.enabled, fallback.enabled),
|
|
sourceScopes: Array.isArray(record.sourceScopes)
|
|
? record.sourceScopes.map(normalizePaperclipIngestionSourceScope).filter((scope): scope is PaperclipIngestionSourceScope => Boolean(scope))
|
|
: fallback.sourceScopes,
|
|
sourceKinds: {
|
|
issues: normalizeBoolean(sourceKinds.issues, fallback.sourceKinds.issues),
|
|
comments: normalizeBoolean(sourceKinds.comments, fallback.sourceKinds.comments),
|
|
documents: normalizeBoolean(sourceKinds.documents, fallback.sourceKinds.documents),
|
|
attachments: sourceKinds.attachments === "metadata_only" ? "metadata_only" : "off",
|
|
workProducts: sourceKinds.workProducts === "metadata_only" ? "metadata_only" : "off",
|
|
},
|
|
cursor: {
|
|
maxWindowCharacters: normalizeLimit(cursor.maxWindowCharacters, fallback.cursor.maxWindowCharacters, DEFAULT_MAX_PAPERCLIP_CURSOR_WINDOW_CHARS),
|
|
maxCharactersPerSource: normalizeLimit(cursor.maxCharactersPerSource, fallback.cursor.maxCharactersPerSource, DEFAULT_MAX_PAPERCLIP_ISSUE_SOURCE_CHARS),
|
|
minSourceAgeMinutes: normalizeLimit(cursor.minSourceAgeMinutes, fallback.cursor.minSourceAgeMinutes, 24 * 60),
|
|
maxWindowsPerRun: normalizeLimit(cursor.maxWindowsPerRun, fallback.cursor.maxWindowsPerRun, 25),
|
|
staleAfterHours: normalizeLimit(cursor.staleAfterHours, fallback.cursor.staleAfterHours, 24 * 30),
|
|
},
|
|
backfill: {
|
|
defaultStartAt: isoString(backfill.defaultStartAt),
|
|
defaultEndAt: isoString(backfill.defaultEndAt),
|
|
requireManualQueue: normalizeBoolean(backfill.requireManualQueue, fallback.backfill.requireManualQueue),
|
|
},
|
|
};
|
|
}
|
|
|
|
async function profileForSpace(ctx: PluginContext, companyId: string, space: WikiSpace): Promise<PaperclipIngestionProfileV1> {
|
|
const legacySettings = space.slug === DEFAULT_SPACE_SLUG ? await getEventIngestionSettings(ctx, companyId) : null;
|
|
return normalizePaperclipIngestionProfile(space.settings.paperclipIngestion, { space, legacySettings });
|
|
}
|
|
|
|
function eventIngestionStateKey(companyId: string) {
|
|
return {
|
|
scopeKind: "company" as const,
|
|
scopeId: companyId,
|
|
namespace: EVENT_INGESTION_STATE_NAMESPACE,
|
|
stateKey: EVENT_INGESTION_STATE_KEY,
|
|
};
|
|
}
|
|
|
|
function eventIngestionDedupKey(companyId: string, wikiId: string, spaceId: string, sourceKind: WikiEventIngestionSource, sourceId: string) {
|
|
return {
|
|
scopeKind: "company" as const,
|
|
scopeId: companyId,
|
|
namespace: EVENT_INGESTION_DEDUP_NAMESPACE,
|
|
stateKey: `${wikiId}:${spaceId}:${sourceKind}:${sourceId}`,
|
|
};
|
|
}
|
|
|
|
export async function getEventIngestionSettings(ctx: PluginContext, companyId: string): Promise<WikiEventIngestionSettings> {
|
|
return normalizeEventIngestionSettings(await ctx.state.get(eventIngestionStateKey(companyId)));
|
|
}
|
|
|
|
function evaluatePaperclipProfilePolicy(input: {
|
|
space: WikiSpace;
|
|
profile?: PaperclipIngestionProfileV1 | null;
|
|
purpose: PaperclipIngestionPolicyPurpose;
|
|
requireEnabledProfile?: boolean;
|
|
}): PaperclipIngestionPolicyDecision {
|
|
const { space, profile, purpose } = input;
|
|
if (space.status !== "active") {
|
|
return {
|
|
allowed: false,
|
|
space,
|
|
reason: "archived_space",
|
|
message: `Paperclip ingestion policy denied ${purpose}: space "${space.slug}" is ${space.status}.`,
|
|
};
|
|
}
|
|
if (space.accessScope !== "shared") {
|
|
return {
|
|
allowed: false,
|
|
space,
|
|
reason: "restricted_space",
|
|
message: `Paperclip ingestion policy denied ${purpose}: ${space.accessScope} spaces cannot ingest Paperclip sources until host permissions are enforced.`,
|
|
};
|
|
}
|
|
if (input.requireEnabledProfile && space.slug !== DEFAULT_SPACE_SLUG && !profile?.enabled) {
|
|
return {
|
|
allowed: false,
|
|
space,
|
|
reason: "profile_disabled",
|
|
message: `Paperclip ingestion policy denied ${purpose}: Paperclip ingestion is not enabled for space "${space.slug}".`,
|
|
};
|
|
}
|
|
if (input.requireEnabledProfile && space.slug !== DEFAULT_SPACE_SLUG && profile?.enabled && profile.sourceScopes.length === 0) {
|
|
return {
|
|
allowed: false,
|
|
space,
|
|
reason: "profile_empty",
|
|
message: `Paperclip ingestion policy denied ${purpose}: space "${space.slug}" has no source scopes configured.`,
|
|
};
|
|
}
|
|
return { allowed: true, space };
|
|
}
|
|
|
|
export async function getPaperclipIngestionProfile(
|
|
ctx: PluginContext,
|
|
input: { companyId: string; wikiId?: string | null; spaceSlug?: string | null },
|
|
): Promise<PaperclipIngestionProfileRead> {
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
const space = await resolveSpace(ctx, { companyId: input.companyId, wikiId, spaceSlug: input.spaceSlug });
|
|
const profile = await profileForSpace(ctx, input.companyId, space);
|
|
const policy = evaluatePaperclipProfilePolicy({ space, profile, purpose: "profile_read" });
|
|
const historicalPageCount = await countPaperclipHistoricalPages(ctx, {
|
|
companyId: input.companyId,
|
|
wikiId,
|
|
spaceId: space.id,
|
|
});
|
|
const overlapCount = await countPaperclipProfileOverlaps(ctx, {
|
|
companyId: input.companyId,
|
|
wikiId,
|
|
space,
|
|
profile,
|
|
});
|
|
const effectiveState: PaperclipIngestionProfileEffectiveState = !policy.allowed
|
|
? "policy_blocked"
|
|
: profile.enabled && profile.sourceScopes.length === 0
|
|
? "enabled_no_scopes"
|
|
: profile.enabled
|
|
? "enabled"
|
|
: "disabled";
|
|
return {
|
|
wikiId,
|
|
space: {
|
|
id: space.id,
|
|
slug: space.slug,
|
|
displayName: space.displayName,
|
|
accessScope: space.accessScope,
|
|
status: space.status,
|
|
},
|
|
profile,
|
|
effectiveState,
|
|
policyBlocks: policy.allowed ? [] : [policy.message],
|
|
historicalPageCount,
|
|
overlapCount,
|
|
};
|
|
}
|
|
|
|
async function countPaperclipHistoricalPages(ctx: PluginContext, input: { companyId: string; wikiId: string; spaceId: string }): Promise<number> {
|
|
const rows = await ctx.db.query<{ count: string | number }>(
|
|
`SELECT count(*)::text AS count
|
|
FROM ${pageBindingTable(ctx)}
|
|
WHERE company_id = $1 AND wiki_id = $2 AND space_id = $3`,
|
|
[input.companyId, input.wikiId, input.spaceId],
|
|
);
|
|
return Number(rows[0]?.count ?? 0) || 0;
|
|
}
|
|
|
|
function scopeIdentity(scope: PaperclipIngestionSourceScope): string[] {
|
|
if (scope.kind === "active_projects") return [`active_projects:${scope.limit}`];
|
|
if (scope.kind === "selected_projects") return scope.projectIds.map((id) => `project:${id}`);
|
|
if (scope.kind === "root_issues") return scope.issueIds.map((id) => `root_issue:${id}`);
|
|
return ["company_all"];
|
|
}
|
|
|
|
async function countPaperclipProfileOverlaps(ctx: PluginContext, input: {
|
|
companyId: string;
|
|
wikiId: string;
|
|
space: WikiSpace;
|
|
profile: PaperclipIngestionProfileV1;
|
|
}): Promise<number> {
|
|
if (!input.profile.enabled || input.profile.sourceScopes.length === 0) return 0;
|
|
const own = new Set(input.profile.sourceScopes.flatMap(scopeIdentity));
|
|
if (own.size === 0) return 0;
|
|
const { spaces } = await listSpaces(ctx, { companyId: input.companyId, wikiId: input.wikiId });
|
|
let overlaps = 0;
|
|
for (const space of spaces) {
|
|
if (space.id === input.space.id) continue;
|
|
const profile = await profileForSpace(ctx, input.companyId, space);
|
|
if (!profile.enabled) continue;
|
|
for (const key of profile.sourceScopes.flatMap(scopeIdentity)) {
|
|
if (own.has(key)) overlaps += 1;
|
|
}
|
|
}
|
|
return overlaps;
|
|
}
|
|
|
|
async function validatePaperclipIngestionProfile(ctx: PluginContext, input: {
|
|
companyId: string;
|
|
space: WikiSpace;
|
|
profile: PaperclipIngestionProfileV1;
|
|
}) {
|
|
const policy = evaluatePaperclipProfilePolicy({
|
|
space: input.space,
|
|
profile: input.profile,
|
|
purpose: "profile_update",
|
|
requireEnabledProfile: input.profile.enabled && input.space.slug !== DEFAULT_SPACE_SLUG,
|
|
});
|
|
if (!policy.allowed) throw new Error(policy.message);
|
|
if (input.profile.enabled && input.profile.sourceScopes.length === 0) {
|
|
throw new Error("Paperclip ingestion profile must include at least one source scope before it can be enabled.");
|
|
}
|
|
if (input.profile.sourceScopes.length > MAX_PAPERCLIP_INGESTION_PROFILE_SOURCE_COUNT) {
|
|
throw new Error(`Paperclip ingestion profile sources exceed the hard cap of ${MAX_PAPERCLIP_INGESTION_PROFILE_SOURCE_COUNT}.`);
|
|
}
|
|
for (const scope of input.profile.sourceScopes) {
|
|
if (scope.kind === "company_all" && input.space.slug !== DEFAULT_SPACE_SLUG) {
|
|
throw new Error("Everything in the company is only available on the default wiki space.");
|
|
}
|
|
if (scope.kind === "selected_projects") {
|
|
if (scope.projectIds.length > MAX_PAPERCLIP_PROFILE_SELECTED_PROJECTS) {
|
|
throw new Error(`selected_projects exceeds the hard cap of ${MAX_PAPERCLIP_PROFILE_SELECTED_PROJECTS}.`);
|
|
}
|
|
for (const projectId of scope.projectIds) {
|
|
const project = await ctx.projects.get(projectId, input.companyId);
|
|
if (!project) throw new Error(`Project belongs to another company or does not exist: ${projectId}`);
|
|
}
|
|
}
|
|
if (scope.kind === "root_issues") {
|
|
if (scope.issueIds.length > MAX_PAPERCLIP_PROFILE_ROOT_ISSUES) {
|
|
throw new Error(`root_issues exceeds the hard cap of ${MAX_PAPERCLIP_PROFILE_ROOT_ISSUES}.`);
|
|
}
|
|
for (const issueId of scope.issueIds) {
|
|
const issue = await ctx.issues.get(issueId, input.companyId);
|
|
if (!issue) throw new Error(`Issue belongs to another company or does not exist: ${issueId}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function updatePaperclipIngestionProfile(ctx: PluginContext, input: {
|
|
companyId: string;
|
|
wikiId?: string | null;
|
|
spaceSlug?: string | null;
|
|
profile: unknown;
|
|
}): Promise<PaperclipIngestionProfileRead> {
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
const space = await resolveSpace(ctx, { companyId: input.companyId, wikiId, spaceSlug: input.spaceSlug });
|
|
const current = await profileForSpace(ctx, input.companyId, space);
|
|
const profile = normalizePaperclipIngestionProfile(input.profile, { space, legacySettings: space.slug === DEFAULT_SPACE_SLUG ? await getEventIngestionSettings(ctx, input.companyId) : null });
|
|
await validatePaperclipIngestionProfile(ctx, { companyId: input.companyId, space, profile });
|
|
await updateSpace(ctx, {
|
|
companyId: input.companyId,
|
|
wikiId,
|
|
spaceSlug: space.slug,
|
|
settings: { paperclipIngestion: profile },
|
|
});
|
|
if (space.slug === DEFAULT_SPACE_SLUG) {
|
|
await ctx.state.set(eventIngestionStateKey(input.companyId), {
|
|
enabled: profile.enabled,
|
|
wikiId,
|
|
maxCharacters: profile.cursor.maxCharactersPerSource,
|
|
sources: {
|
|
issues: profile.sourceKinds.issues,
|
|
comments: profile.sourceKinds.comments,
|
|
documents: profile.sourceKinds.documents,
|
|
},
|
|
});
|
|
}
|
|
await ctx.activity.log({
|
|
companyId: input.companyId,
|
|
message: `Updated Paperclip ingestion profile for ${space.displayName}`,
|
|
entityType: "llm_wiki_space",
|
|
entityId: space.id,
|
|
metadata: {
|
|
type: "plugin.llm_wiki.paperclip_ingestion_profile_updated",
|
|
wikiId,
|
|
spaceSlug: space.slug,
|
|
beforeEnabled: current.enabled,
|
|
afterEnabled: profile.enabled,
|
|
sourceScopeKinds: profile.sourceScopes.map((scope) => scope.kind),
|
|
sourceKinds: profile.sourceKinds,
|
|
cursor: profile.cursor,
|
|
},
|
|
});
|
|
return getPaperclipIngestionProfile(ctx, { companyId: input.companyId, wikiId, spaceSlug: space.slug });
|
|
}
|
|
|
|
export async function listPaperclipIngestionCandidates(ctx: PluginContext, input: PaperclipIngestionCandidatesInput): Promise<{
|
|
projects: Array<{ id: string; name: string; status: string; updatedAt: string | null }>;
|
|
rootIssues: Array<{ id: string; identifier: string | null; title: string; status: string; projectId: string | null }>;
|
|
}> {
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
await requirePaperclipIngestionPolicy(ctx, { companyId: input.companyId, wikiId, spaceSlug: input.spaceSlug }, "candidate_search");
|
|
const query = stringField(input.query)?.toLowerCase() ?? "";
|
|
const projects = (await ctx.projects.list({ companyId: input.companyId, limit: 200 }))
|
|
.filter((project) => !project.archivedAt)
|
|
.filter((project) => !query || project.name.toLowerCase().includes(query))
|
|
.slice(0, 50)
|
|
.map((project) => ({
|
|
id: project.id,
|
|
name: project.name,
|
|
status: project.status,
|
|
updatedAt: isoString(project.updatedAt),
|
|
}));
|
|
const issues = (await ctx.issues.list({
|
|
companyId: input.companyId,
|
|
includePluginOperations: false,
|
|
limit: 200,
|
|
}))
|
|
.filter((issue) => !issue.parentId)
|
|
.filter((issue) => !query || issue.title.toLowerCase().includes(query) || issue.identifier?.toLowerCase().includes(query))
|
|
.slice(0, 50)
|
|
.map((issue) => ({
|
|
id: issue.id,
|
|
identifier: issue.identifier ?? null,
|
|
title: issue.title,
|
|
status: issue.status,
|
|
projectId: issue.projectId ?? null,
|
|
}));
|
|
return { projects, rootIssues: issues };
|
|
}
|
|
|
|
export async function updateEventIngestionSettings(
|
|
ctx: PluginContext,
|
|
input: { companyId: string; settings: WikiEventIngestionSettingsUpdate },
|
|
): Promise<WikiEventIngestionSettings> {
|
|
await requirePaperclipIngestionPolicy(ctx, {
|
|
companyId: input.companyId,
|
|
wikiId: normalizeWikiId(input.settings.wikiId),
|
|
spaceSlug: DEFAULT_SPACE_SLUG,
|
|
}, "profile_update");
|
|
const sourceKeys = Object.keys(input.settings.sources ?? {});
|
|
if (sourceKeys.length > MAX_PAPERCLIP_INGESTION_PROFILE_SOURCE_COUNT) {
|
|
throw new Error(`Paperclip ingestion profile sources exceed the hard cap of ${MAX_PAPERCLIP_INGESTION_PROFILE_SOURCE_COUNT}.`);
|
|
}
|
|
assertRequestedCharacterLimit("maxCharacters", input.settings.maxCharacters, MAX_EVENT_SOURCE_CHARS);
|
|
const current = await getEventIngestionSettings(ctx, input.companyId);
|
|
const next = normalizeEventIngestionSettings({
|
|
...current,
|
|
...input.settings,
|
|
sources: {
|
|
...current.sources,
|
|
...(input.settings.sources ?? {}),
|
|
},
|
|
});
|
|
await ctx.state.set(eventIngestionStateKey(input.companyId), next);
|
|
const defaultSpace = await ensureDefaultSpace(ctx, { companyId: input.companyId, wikiId: next.wikiId });
|
|
const profile = normalizePaperclipIngestionProfile(
|
|
{
|
|
...defaultPaperclipIngestionProfile({ space: defaultSpace, legacySettings: next }),
|
|
enabled: next.enabled,
|
|
sourceKinds: {
|
|
issues: next.sources.issues,
|
|
comments: next.sources.comments,
|
|
documents: next.sources.documents,
|
|
attachments: "off",
|
|
workProducts: "off",
|
|
},
|
|
cursor: {
|
|
...defaultPaperclipIngestionProfile({ space: defaultSpace, legacySettings: next }).cursor,
|
|
maxCharactersPerSource: next.maxCharacters,
|
|
},
|
|
},
|
|
{ space: defaultSpace, legacySettings: next },
|
|
);
|
|
await updateSpace(ctx, {
|
|
companyId: input.companyId,
|
|
wikiId: next.wikiId,
|
|
spaceSlug: DEFAULT_SPACE_SLUG,
|
|
settings: { paperclipIngestion: profile },
|
|
});
|
|
return next;
|
|
}
|
|
|
|
function assertWikiPath(path: string, options: { allowMetadata?: boolean } = {}): string {
|
|
const trimmed = path.trim().replace(/^\/+/, "");
|
|
if (
|
|
!trimmed ||
|
|
trimmed.includes("\\") ||
|
|
trimmed.split("/").some((segment) => segment === "" || segment === "." || segment === "..")
|
|
) {
|
|
throw new Error(`Invalid wiki path: ${path}`);
|
|
}
|
|
if (
|
|
trimmed !== ".gitignore" &&
|
|
trimmed !== "WIKI.md" &&
|
|
trimmed !== "AGENTS.md" &&
|
|
trimmed !== "IDEA.md" &&
|
|
trimmed !== "index.md" &&
|
|
trimmed !== "log.md" &&
|
|
!trimmed.startsWith("raw/") &&
|
|
!trimmed.startsWith("wiki/") &&
|
|
!(options.allowMetadata && trimmed.startsWith(".paperclip/"))
|
|
) {
|
|
throw new Error(`Wiki path must stay inside AGENTS.md, IDEA.md, raw/, or wiki/: ${path}`);
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function assertPagePath(path: string): string {
|
|
const normalized = assertWikiPath(path);
|
|
if (normalized !== "index.md" && normalized !== "log.md" && normalized !== "WIKI.md" && normalized !== "AGENTS.md" && normalized !== "IDEA.md" && !normalized.startsWith("wiki/")) {
|
|
throw new Error(`Wiki page writes must target AGENTS.md, IDEA.md, or wiki/: ${path}`);
|
|
}
|
|
if (!normalized.endsWith(".md")) {
|
|
throw new Error(`Wiki page path must be a markdown file: ${path}`);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function assertPageWriteAllowed(path: string, writer: WritePageInput["writer"] = "agent_tool"): void {
|
|
if (writer !== "board_ui" && PROTECTED_WIKI_CONTROL_FILES.has(path)) {
|
|
throw new Error(`Refusing to overwrite protected wiki control file ${path}; board-managed edits must use the wiki UI.`);
|
|
}
|
|
}
|
|
|
|
function assertRawPath(path: string): string {
|
|
const normalized = assertWikiPath(path);
|
|
if (!normalized.startsWith("raw/")) {
|
|
throw new Error(`Source path must stay inside raw/: ${path}`);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function tableName(namespace: string, table: string): string {
|
|
return `${namespace}.${table}`;
|
|
}
|
|
|
|
function spaceTable(ctx: PluginContext): string {
|
|
return tableName(ctx.db.namespace, "wiki_spaces");
|
|
}
|
|
|
|
function bindingTable(ctx: PluginContext): string {
|
|
return tableName(ctx.db.namespace, "wiki_resource_bindings");
|
|
}
|
|
|
|
function distillationCursorTable(ctx: PluginContext): string {
|
|
return tableName(ctx.db.namespace, "paperclip_distillation_cursors");
|
|
}
|
|
|
|
function distillationRunTable(ctx: PluginContext): string {
|
|
return tableName(ctx.db.namespace, "paperclip_distillation_runs");
|
|
}
|
|
|
|
function sourceSnapshotTable(ctx: PluginContext): string {
|
|
return tableName(ctx.db.namespace, "paperclip_source_snapshots");
|
|
}
|
|
|
|
function distillationWorkItemTable(ctx: PluginContext): string {
|
|
return tableName(ctx.db.namespace, "paperclip_distillation_work_items");
|
|
}
|
|
|
|
function pageBindingTable(ctx: PluginContext): string {
|
|
return tableName(ctx.db.namespace, "paperclip_page_bindings");
|
|
}
|
|
|
|
function parseBindingMetadata(value: unknown): Record<string, unknown> {
|
|
if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
|
|
if (typeof value === "string") {
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
return {};
|
|
}
|
|
|
|
function parseJsonObject(value: unknown): Record<string, unknown> {
|
|
if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
|
|
if (typeof value === "string") {
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
return {};
|
|
}
|
|
|
|
type WikiSpaceRow = {
|
|
id: string;
|
|
company_id: string;
|
|
wiki_id: string;
|
|
slug: string;
|
|
display_name: string;
|
|
space_type: string;
|
|
folder_mode: string;
|
|
root_folder_key: string;
|
|
path_prefix: string | null;
|
|
configured_root_path: string | null;
|
|
access_scope: string;
|
|
owner_user_id: string | null;
|
|
owner_agent_id: string | null;
|
|
team_key: string | null;
|
|
settings: unknown;
|
|
status: string;
|
|
created_at: string | null;
|
|
updated_at: string | null;
|
|
};
|
|
|
|
function wikiSpaceFromRow(row: WikiSpaceRow): WikiSpace {
|
|
return {
|
|
id: row.id,
|
|
companyId: row.company_id,
|
|
wikiId: row.wiki_id,
|
|
slug: row.slug,
|
|
displayName: row.display_name,
|
|
spaceType: row.space_type,
|
|
folderMode: row.folder_mode,
|
|
rootFolderKey: row.root_folder_key,
|
|
pathPrefix: row.path_prefix,
|
|
configuredRootPath: row.configured_root_path,
|
|
accessScope: row.access_scope,
|
|
ownerUserId: row.owner_user_id,
|
|
ownerAgentId: row.owner_agent_id,
|
|
teamKey: row.team_key,
|
|
settings: parseJsonObject(row.settings),
|
|
status: row.status,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
};
|
|
}
|
|
|
|
function fallbackDefaultSpace(input: { companyId: string; wikiId: string }): WikiSpace {
|
|
return {
|
|
id: stableSpaceId({ companyId: input.companyId, wikiId: input.wikiId, slug: DEFAULT_SPACE_SLUG }),
|
|
companyId: input.companyId,
|
|
wikiId: input.wikiId,
|
|
slug: DEFAULT_SPACE_SLUG,
|
|
displayName: DEFAULT_SPACE_SLUG,
|
|
spaceType: "local_folder",
|
|
folderMode: "managed_subfolder",
|
|
rootFolderKey: WIKI_ROOT_FOLDER_KEY,
|
|
pathPrefix: null,
|
|
configuredRootPath: null,
|
|
accessScope: "shared",
|
|
ownerUserId: null,
|
|
ownerAgentId: null,
|
|
teamKey: null,
|
|
settings: {},
|
|
status: "active",
|
|
createdAt: null,
|
|
updatedAt: null,
|
|
};
|
|
}
|
|
|
|
export async function ensureDefaultSpace(ctx: PluginContext, input: { companyId: string; wikiId?: string | null }): Promise<WikiSpace> {
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
const id = stableSpaceId({ companyId: input.companyId, wikiId, slug: DEFAULT_SPACE_SLUG });
|
|
await ctx.db.execute(
|
|
`INSERT INTO ${spaceTable(ctx)} AS wiki_spaces
|
|
(id, company_id, wiki_id, slug, display_name, space_type, folder_mode, root_folder_key, path_prefix, access_scope, status, settings)
|
|
VALUES ($1, $2, $3, 'default', 'default', 'local_folder', 'managed_subfolder', $4, NULL, 'shared', 'active', '{}'::jsonb)
|
|
ON CONFLICT (company_id, wiki_id, slug)
|
|
DO UPDATE SET updated_at = wiki_spaces.updated_at`,
|
|
[id, input.companyId, wikiId, WIKI_ROOT_FOLDER_KEY],
|
|
);
|
|
const rows = await ctx.db.query<WikiSpaceRow>(
|
|
`SELECT id, company_id, wiki_id, slug, display_name, space_type, folder_mode, root_folder_key,
|
|
path_prefix, configured_root_path, access_scope, owner_user_id, owner_agent_id, team_key,
|
|
settings, status, created_at::text AS created_at, updated_at::text AS updated_at
|
|
FROM ${spaceTable(ctx)}
|
|
WHERE company_id = $1 AND wiki_id = $2 AND slug = 'default'
|
|
LIMIT 1`,
|
|
[input.companyId, wikiId],
|
|
);
|
|
return rows[0] ? wikiSpaceFromRow(rows[0]) : fallbackDefaultSpace({ companyId: input.companyId, wikiId });
|
|
}
|
|
|
|
export async function resolveSpace(ctx: PluginContext, input: SpaceInput): Promise<WikiSpace> {
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
const slug = normalizeSpaceSlug(input.spaceSlug);
|
|
if (slug === DEFAULT_SPACE_SLUG) {
|
|
return ensureDefaultSpace(ctx, { companyId: input.companyId, wikiId });
|
|
}
|
|
const rows = await ctx.db.query<WikiSpaceRow>(
|
|
`SELECT id, company_id, wiki_id, slug, display_name, space_type, folder_mode, root_folder_key,
|
|
path_prefix, configured_root_path, access_scope, owner_user_id, owner_agent_id, team_key,
|
|
settings, status, created_at::text AS created_at, updated_at::text AS updated_at
|
|
FROM ${spaceTable(ctx)}
|
|
WHERE company_id = $1 AND wiki_id = $2 AND slug = $3 AND status <> 'archived'
|
|
LIMIT 1`,
|
|
[input.companyId, wikiId, slug],
|
|
);
|
|
if (!rows[0]) throw new Error(`LLM Wiki space not found: ${slug}`);
|
|
return wikiSpaceFromRow(rows[0]);
|
|
}
|
|
|
|
async function resolveSpaceAnyStatus(ctx: PluginContext, input: SpaceInput): Promise<WikiSpace> {
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
const slug = normalizeSpaceSlug(input.spaceSlug);
|
|
if (slug === DEFAULT_SPACE_SLUG) {
|
|
return ensureDefaultSpace(ctx, { companyId: input.companyId, wikiId });
|
|
}
|
|
const rows = await ctx.db.query<WikiSpaceRow>(
|
|
`SELECT id, company_id, wiki_id, slug, display_name, space_type, folder_mode, root_folder_key,
|
|
path_prefix, configured_root_path, access_scope, owner_user_id, owner_agent_id, team_key,
|
|
settings, status, created_at::text AS created_at, updated_at::text AS updated_at
|
|
FROM ${spaceTable(ctx)}
|
|
WHERE company_id = $1 AND wiki_id = $2 AND slug = $3
|
|
LIMIT 1`,
|
|
[input.companyId, wikiId, slug],
|
|
);
|
|
if (!rows[0]) throw new Error(`LLM Wiki space not found: ${slug}`);
|
|
return wikiSpaceFromRow(rows[0]);
|
|
}
|
|
|
|
export async function listSpaces(ctx: PluginContext, input: { companyId: string; wikiId?: string | null }): Promise<{ spaces: WikiSpace[] }> {
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
await ensureDefaultSpace(ctx, { companyId: input.companyId, wikiId });
|
|
const rows = await ctx.db.query<WikiSpaceRow>(
|
|
`SELECT id, company_id, wiki_id, slug, display_name, space_type, folder_mode, root_folder_key,
|
|
path_prefix, configured_root_path, access_scope, owner_user_id, owner_agent_id, team_key,
|
|
settings, status, created_at::text AS created_at, updated_at::text AS updated_at
|
|
FROM ${spaceTable(ctx)}
|
|
WHERE company_id = $1 AND wiki_id = $2 AND status <> 'archived'
|
|
ORDER BY CASE WHEN slug = 'default' THEN 0 ELSE 1 END, display_name, slug`,
|
|
[input.companyId, wikiId],
|
|
);
|
|
const spaces = rows.length > 0 ? rows.map(wikiSpaceFromRow) : [fallbackDefaultSpace({ companyId: input.companyId, wikiId })];
|
|
return { spaces };
|
|
}
|
|
|
|
export async function createSpace(ctx: PluginContext, input: CreateSpaceInput): Promise<{ status: "created"; space: WikiSpace }> {
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
const displayName = stringField(input.displayName) ?? stringField(input.slug) ?? "New space";
|
|
const slug = normalizeSpaceSlug(input.slug ?? displayName);
|
|
if (slug === DEFAULT_SPACE_SLUG) {
|
|
return { status: "created", space: await ensureDefaultSpace(ctx, { companyId: input.companyId, wikiId }) };
|
|
}
|
|
const folderMode = input.folderMode ?? "managed_subfolder";
|
|
if (folderMode !== "managed_subfolder") {
|
|
throw new Error("Only managed_subfolder spaces are supported until dynamic local folder bindings are available.");
|
|
}
|
|
const accessScope = input.accessScope ?? "shared";
|
|
const id = randomUUID();
|
|
const pathPrefix = `spaces/${slug}`;
|
|
await ctx.db.execute(
|
|
`INSERT INTO ${spaceTable(ctx)}
|
|
(id, company_id, wiki_id, slug, display_name, space_type, folder_mode, root_folder_key, path_prefix, access_scope, settings, status)
|
|
VALUES ($1, $2, $3, $4, $5, 'local_folder', $6, $7, $8, $9, $10::jsonb, 'active')`,
|
|
[
|
|
id,
|
|
input.companyId,
|
|
wikiId,
|
|
slug,
|
|
displayName,
|
|
folderMode,
|
|
WIKI_ROOT_FOLDER_KEY,
|
|
pathPrefix,
|
|
accessScope,
|
|
jsonParam(input.settings ?? {}),
|
|
],
|
|
);
|
|
const space: WikiSpace = {
|
|
id,
|
|
companyId: input.companyId,
|
|
wikiId,
|
|
slug,
|
|
displayName,
|
|
spaceType: "local_folder",
|
|
folderMode,
|
|
rootFolderKey: WIKI_ROOT_FOLDER_KEY,
|
|
pathPrefix,
|
|
configuredRootPath: null,
|
|
accessScope,
|
|
ownerUserId: null,
|
|
ownerAgentId: null,
|
|
teamKey: null,
|
|
settings: input.settings ?? {},
|
|
status: "active",
|
|
createdAt: null,
|
|
updatedAt: null,
|
|
};
|
|
await bootstrapSpaceFiles(ctx, input.companyId, space);
|
|
await upsertWikiInstance(ctx, { companyId: input.companyId, wikiId });
|
|
return { status: "created", space };
|
|
}
|
|
|
|
export async function updateSpace(ctx: PluginContext, input: UpdateSpaceInput): Promise<{ status: "ok"; space: WikiSpace }> {
|
|
const nextStatus = input.status ?? null;
|
|
if (nextStatus !== null && nextStatus !== "active" && nextStatus !== "archived") {
|
|
throw new Error("LLM Wiki space status must be active or archived.");
|
|
}
|
|
const space = nextStatus === "active" ? await resolveSpaceAnyStatus(ctx, input) : await resolveSpace(ctx, input);
|
|
const nextDisplayName = stringField(input.displayName);
|
|
if (space.slug === DEFAULT_SPACE_SLUG && nextStatus === "archived") {
|
|
throw new Error("The default LLM Wiki space cannot be archived.");
|
|
}
|
|
await ctx.db.execute(
|
|
`UPDATE ${spaceTable(ctx)}
|
|
SET display_name = COALESCE($4, display_name),
|
|
settings = CASE WHEN $5::jsonb IS NULL THEN settings ELSE settings || $5::jsonb END,
|
|
status = COALESCE($6, status),
|
|
updated_at = now()
|
|
WHERE company_id = $1 AND wiki_id = $2 AND slug = $3`,
|
|
[
|
|
input.companyId,
|
|
space.wikiId,
|
|
space.slug,
|
|
nextDisplayName,
|
|
input.settings ? jsonParam(input.settings) : null,
|
|
nextStatus ?? null,
|
|
],
|
|
);
|
|
if (nextStatus === "archived") {
|
|
return {
|
|
status: "ok",
|
|
space: {
|
|
...space,
|
|
displayName: nextDisplayName ?? space.displayName,
|
|
settings: input.settings ? { ...space.settings, ...input.settings } : space.settings,
|
|
status: "archived",
|
|
},
|
|
};
|
|
}
|
|
return { status: "ok", space: await resolveSpace(ctx, { companyId: input.companyId, wikiId: space.wikiId, spaceSlug: space.slug }) };
|
|
}
|
|
|
|
export async function archiveSpace(ctx: PluginContext, input: SpaceInput): Promise<{ status: "archived"; space: WikiSpace }> {
|
|
const space = await resolveSpace(ctx, input);
|
|
if (space.slug === DEFAULT_SPACE_SLUG) throw new Error("The default LLM Wiki space cannot be archived.");
|
|
await ctx.db.execute(
|
|
`UPDATE ${spaceTable(ctx)}
|
|
SET status = 'archived', updated_at = now()
|
|
WHERE company_id = $1 AND wiki_id = $2 AND slug = $3`,
|
|
[input.companyId, space.wikiId, space.slug],
|
|
);
|
|
return { status: "archived", space: { ...space, status: "archived" } };
|
|
}
|
|
|
|
export function spaceRelativePath(space: Pick<WikiSpace, "pathPrefix">, path: string): string {
|
|
const normalized = path.replace(/^\/+/, "");
|
|
return space.pathPrefix ? `${space.pathPrefix}/${normalized}` : normalized;
|
|
}
|
|
|
|
function logicalPathFromSpacePath(space: Pick<WikiSpace, "pathPrefix">, path: string): string {
|
|
if (!space.pathPrefix) return path;
|
|
const prefix = `${space.pathPrefix}/`;
|
|
return path.startsWith(prefix) ? path.slice(prefix.length) : path;
|
|
}
|
|
|
|
export async function spaceFolderStatus(ctx: PluginContext, input: SpaceInput): Promise<WikiSpaceWithFolderStatus> {
|
|
const space = await resolveSpace(ctx, input);
|
|
const folder = await ctx.localFolders.status(input.companyId, WIKI_ROOT_FOLDER_KEY);
|
|
return {
|
|
...space,
|
|
relativeRoot: space.pathPrefix ?? ".",
|
|
folder,
|
|
};
|
|
}
|
|
|
|
async function getResourceBinding(
|
|
ctx: PluginContext,
|
|
input: { companyId: string; wikiId: string; resourceKind: WikiResourceKind; resourceKey: string },
|
|
): Promise<WikiResourceBinding | null> {
|
|
const rows = await ctx.db.query<{ resolved_id: string | null; metadata: unknown }>(
|
|
`SELECT resolved_id, metadata
|
|
FROM ${bindingTable(ctx)}
|
|
WHERE company_id = $1
|
|
AND wiki_id = $2
|
|
AND resource_kind = $3
|
|
AND resource_key = $4
|
|
LIMIT 1`,
|
|
[input.companyId, input.wikiId, input.resourceKind, input.resourceKey],
|
|
);
|
|
const row = rows[0];
|
|
return row ? { resolvedId: row.resolved_id, metadata: parseBindingMetadata(row.metadata) } : null;
|
|
}
|
|
|
|
async function upsertResourceBinding(
|
|
ctx: PluginContext,
|
|
input: { companyId: string; wikiId: string; resourceKind: WikiResourceKind; resourceKey: string; resolvedId: string; metadata?: Record<string, unknown> },
|
|
) {
|
|
await ctx.db.execute(
|
|
`INSERT INTO ${bindingTable(ctx)} AS wiki_resource_bindings
|
|
(id, company_id, wiki_id, resource_kind, resource_key, resolved_id, metadata)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
|
|
ON CONFLICT (company_id, wiki_id, resource_kind, resource_key)
|
|
DO UPDATE SET resolved_id = EXCLUDED.resolved_id,
|
|
metadata = EXCLUDED.metadata,
|
|
updated_at = now()`,
|
|
[
|
|
randomUUID(),
|
|
input.companyId,
|
|
input.wikiId,
|
|
input.resourceKind,
|
|
input.resourceKey,
|
|
input.resolvedId,
|
|
jsonParam(input.metadata ?? {}),
|
|
],
|
|
);
|
|
}
|
|
|
|
function agentDetails(agent: Agent | null): WikiAgentResource["details"] {
|
|
return agent
|
|
? { name: agent.name, status: agent.status, adapterType: agent.adapterType ?? null, icon: agent.icon ?? null, urlKey: agent.urlKey ?? null }
|
|
: null;
|
|
}
|
|
|
|
function projectDetails(project: Project | null): WikiProjectResource["details"] {
|
|
return project ? { name: project.name, status: project.status, color: project.color ?? null } : null;
|
|
}
|
|
|
|
function skillDetails(skill: PluginManagedSkillResolution["skill"]): WikiSkillResource["details"] {
|
|
return skill
|
|
? { name: skill.name, key: skill.key, description: skill.description ?? null }
|
|
: null;
|
|
}
|
|
|
|
function agentResource(input: {
|
|
status: WikiAgentResource["status"];
|
|
source: WikiAgentResource["source"];
|
|
agent: Agent | null;
|
|
defaultDrift?: WikiAgentResource["defaultDrift"];
|
|
}): WikiAgentResource {
|
|
return {
|
|
status: input.status,
|
|
source: input.source,
|
|
agentId: input.agent?.id ?? null,
|
|
resourceKey: `${PLUGIN_ID}:agent:${WIKI_MAINTAINER_AGENT_KEY}`,
|
|
agent: input.agent,
|
|
details: agentDetails(input.agent),
|
|
defaultDrift: input.defaultDrift ?? null,
|
|
};
|
|
}
|
|
|
|
function projectResource(input: {
|
|
status: WikiProjectResource["status"];
|
|
source: WikiProjectResource["source"];
|
|
project: Project | null;
|
|
}): WikiProjectResource {
|
|
return {
|
|
status: input.status,
|
|
source: input.source,
|
|
projectId: input.project?.id ?? null,
|
|
resourceKey: `${PLUGIN_ID}:project:${WIKI_PROJECT_KEY}`,
|
|
project: input.project,
|
|
details: projectDetails(input.project),
|
|
};
|
|
}
|
|
|
|
function skillResource(resolved: PluginManagedSkillResolution): WikiSkillResource {
|
|
return {
|
|
status: resolved.status,
|
|
skillId: resolved.skillId,
|
|
resourceKey: resolved.resourceKey,
|
|
skill: resolved.skill,
|
|
details: skillDetails(resolved.skill),
|
|
defaultDrift: resolved.defaultDrift ?? null,
|
|
};
|
|
}
|
|
|
|
async function resolveSelectedAgent(ctx: PluginContext, companyId: string, binding: WikiResourceBinding | null) {
|
|
if (!binding?.resolvedId) return null;
|
|
const agent = await ctx.agents.get(binding.resolvedId, companyId);
|
|
return agent && agent.status !== "terminated" ? agent : null;
|
|
}
|
|
|
|
async function resolveSelectedProject(ctx: PluginContext, companyId: string, binding: WikiResourceBinding | null) {
|
|
if (!binding?.resolvedId) return null;
|
|
return ctx.projects.get(binding.resolvedId, companyId);
|
|
}
|
|
|
|
function inferTitle(path: string, contents: string): string {
|
|
const heading = contents.match(/^#\s+(.+)$/m)?.[1]?.trim();
|
|
if (heading) return heading;
|
|
const filename = path.split("/").pop()?.replace(/\.md$/i, "") ?? path;
|
|
return filename.replace(/[-_]+/g, " ");
|
|
}
|
|
|
|
function inferPageType(path: string): string | null {
|
|
if (/^wiki\/projects\/[^/]+\/standup\.md$/.test(path)) return "project-standup";
|
|
const match = path.match(/^wiki\/([^/]+)\//);
|
|
return match?.[1] ?? (path === "index.md" || path === "wiki/index.md" ? "index" : path === "log.md" || path === "wiki/log.md" ? "log" : null);
|
|
}
|
|
|
|
function extractWikiLinks(contents: string): string[] {
|
|
const links = new Set<string>();
|
|
const markdownLinkPattern = /\[[^\]]+\]\(([^)]+)\)/g;
|
|
for (const match of contents.matchAll(markdownLinkPattern)) {
|
|
const target = match[1]?.split("#")[0]?.trim();
|
|
if (target && (target.startsWith("wiki/") || target === "index.md" || target === "log.md" || target === "AGENTS.md" || target === "IDEA.md")) {
|
|
links.add(target);
|
|
}
|
|
}
|
|
const wikiTokenPattern = /\bwiki\/[A-Za-z0-9._/-]+\.md\b/g;
|
|
for (const match of contents.matchAll(wikiTokenPattern)) {
|
|
links.add(match[0]);
|
|
}
|
|
return [...links].sort();
|
|
}
|
|
|
|
async function readCurrentWithHash(
|
|
ctx: PluginContext,
|
|
companyId: string,
|
|
path: string,
|
|
space: Pick<WikiSpace, "pathPrefix">,
|
|
): Promise<{ contents: string | null; hash: string | null }> {
|
|
try {
|
|
const contents = await ctx.localFolders.readText(companyId, WIKI_ROOT_FOLDER_KEY, spaceRelativePath(space, path));
|
|
return { contents, hash: contentHash(contents) };
|
|
} catch {
|
|
return { contents: null, hash: null };
|
|
}
|
|
}
|
|
|
|
async function filterReadableRows<T>(
|
|
ctx: PluginContext,
|
|
companyId: string,
|
|
space: Pick<WikiSpace, "pathPrefix">,
|
|
rows: T[],
|
|
pathForRow: (row: T) => string,
|
|
): Promise<T[]> {
|
|
const checks: Array<T | null> = await Promise.all(rows.map(async (row): Promise<T | null> => {
|
|
try {
|
|
await ctx.localFolders.readText(companyId, WIKI_ROOT_FOLDER_KEY, spaceRelativePath(space, pathForRow(row)));
|
|
return row;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}));
|
|
return checks.filter((row): row is T => row != null);
|
|
}
|
|
|
|
async function listLocalFiles(ctx: PluginContext, input: { companyId: string; space: Pick<WikiSpace, "pathPrefix">; relativePath: "wiki" | "raw" }): Promise<PluginLocalFolderEntry[]> {
|
|
try {
|
|
const relativePath = spaceRelativePath(input.space, input.relativePath);
|
|
const listing = await ctx.localFolders.list(input.companyId, WIKI_ROOT_FOLDER_KEY, {
|
|
relativePath,
|
|
recursive: true,
|
|
maxEntries: LOCAL_BROWSE_FILE_LIMIT,
|
|
});
|
|
return listing.entries
|
|
.filter((entry) => entry.kind === "file")
|
|
.map((entry) => ({ ...entry, path: logicalPathFromSpacePath(input.space, entry.path) }));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function mergeLocalPageRows(pages: WikiPageRow[], entries: PluginLocalFolderEntry[]): WikiPageRow[] {
|
|
const byPath = new Map(pages.map((page) => [page.path, page]));
|
|
for (const entry of entries) {
|
|
if (!entry.path.endsWith(".md") || byPath.has(entry.path)) continue;
|
|
byPath.set(entry.path, {
|
|
path: entry.path,
|
|
title: null,
|
|
pageType: inferPageType(entry.path),
|
|
backlinkCount: 0,
|
|
sourceCount: 0,
|
|
contentHash: null,
|
|
updatedAt: entry.modifiedAt ?? new Date(0).toISOString(),
|
|
});
|
|
}
|
|
return [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
}
|
|
|
|
function mergeLocalSourceRows(sources: WikiSourceRow[], entries: PluginLocalFolderEntry[]): WikiSourceRow[] {
|
|
const byPath = new Map(sources.map((source) => [source.rawPath, source]));
|
|
for (const entry of entries) {
|
|
if (!entry.path.endsWith(".md") || byPath.has(entry.path)) continue;
|
|
byPath.set(entry.path, {
|
|
rawPath: entry.path,
|
|
title: null,
|
|
sourceType: "local_file",
|
|
url: null,
|
|
status: "present",
|
|
createdAt: entry.modifiedAt ?? new Date(0).toISOString(),
|
|
});
|
|
}
|
|
return [...byPath.values()].sort((a, b) => a.rawPath.localeCompare(b.rawPath));
|
|
}
|
|
|
|
function assertExpectedHash(expectedHash: string | null | undefined, currentHash: string | null, path: string): void {
|
|
if (expectedHash && currentHash && expectedHash !== currentHash) {
|
|
throw new Error(`Refusing to overwrite ${path}: expected hash ${expectedHash} but current hash is ${currentHash}`);
|
|
}
|
|
}
|
|
|
|
async function upsertWikiInstance(ctx: PluginContext, input: { companyId: string; wikiId: string; rootPath?: string | null }) {
|
|
await ctx.db.execute(
|
|
`INSERT INTO ${tableName(ctx.db.namespace, "wiki_instances")} AS wiki_instances
|
|
(id, company_id, wiki_id, root_folder_key, configured_root_path, schema_version, settings, managed_agent_key, managed_project_key)
|
|
VALUES ($1, $2, $3, $4, $5, 1, '{}'::jsonb, $6, $7)
|
|
ON CONFLICT (company_id, wiki_id)
|
|
DO UPDATE SET configured_root_path = COALESCE(EXCLUDED.configured_root_path, wiki_instances.configured_root_path),
|
|
managed_agent_key = EXCLUDED.managed_agent_key,
|
|
managed_project_key = EXCLUDED.managed_project_key,
|
|
updated_at = now()`,
|
|
[
|
|
randomUUID(),
|
|
input.companyId,
|
|
input.wikiId,
|
|
WIKI_ROOT_FOLDER_KEY,
|
|
input.rootPath ?? null,
|
|
WIKI_MAINTAINER_AGENT_KEY,
|
|
WIKI_PROJECT_KEY,
|
|
],
|
|
);
|
|
}
|
|
|
|
async function upsertPageMetadata(ctx: PluginContext, input: {
|
|
companyId: string;
|
|
wikiId: string;
|
|
spaceId: string;
|
|
path: string;
|
|
contents: string;
|
|
summary?: string | null;
|
|
sourceRefs?: unknown;
|
|
operationId?: string | null;
|
|
}) {
|
|
const pageId = randomUUID();
|
|
const revisionId = randomUUID();
|
|
const hash = contentHash(input.contents);
|
|
const title = inferTitle(input.path, input.contents);
|
|
const pageType = inferPageType(input.path);
|
|
const backlinks = extractWikiLinks(input.contents);
|
|
const sourceRefs = Array.isArray(input.sourceRefs) ? input.sourceRefs : [];
|
|
|
|
await ctx.db.execute(
|
|
`INSERT INTO ${tableName(ctx.db.namespace, "wiki_pages")}
|
|
(id, company_id, wiki_id, space_id, path, title, page_type, frontmatter, source_refs, backlinks, content_hash, current_revision_id)
|
|
VALUES ($1, $2, $3, $11, $4, $5, $6, '{}'::jsonb, $7::jsonb, $8::jsonb, $9, $10)
|
|
ON CONFLICT (company_id, wiki_id, space_id, path)
|
|
DO UPDATE SET title = EXCLUDED.title,
|
|
page_type = EXCLUDED.page_type,
|
|
source_refs = EXCLUDED.source_refs,
|
|
backlinks = EXCLUDED.backlinks,
|
|
content_hash = EXCLUDED.content_hash,
|
|
current_revision_id = EXCLUDED.current_revision_id,
|
|
updated_at = now()`,
|
|
[
|
|
pageId,
|
|
input.companyId,
|
|
input.wikiId,
|
|
input.path,
|
|
title,
|
|
pageType,
|
|
jsonParam(sourceRefs),
|
|
jsonParam(backlinks),
|
|
hash,
|
|
revisionId,
|
|
input.spaceId,
|
|
],
|
|
);
|
|
|
|
await ctx.db.execute(
|
|
`INSERT INTO ${tableName(ctx.db.namespace, "wiki_page_revisions")}
|
|
(id, company_id, wiki_id, space_id, page_id, operation_id, path, content_hash, summary, metadata)
|
|
VALUES ($1, $2, $3, $8, (SELECT id FROM ${tableName(ctx.db.namespace, "wiki_pages")} WHERE company_id = $2 AND wiki_id = $3 AND space_id = $8 AND path = $4), $7, $4, $5, $6, '{}'::jsonb)`,
|
|
[revisionId, input.companyId, input.wikiId, input.path, hash, input.summary ?? null, input.operationId ?? null, input.spaceId],
|
|
);
|
|
|
|
return { title, pageType, backlinks, hash, revisionId };
|
|
}
|
|
|
|
export async function writeWikiPage(ctx: PluginContext, input: WritePageInput) {
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
const space = await resolveSpace(ctx, { companyId: input.companyId, wikiId, spaceSlug: input.spaceSlug });
|
|
const path = assertPagePath(input.path);
|
|
assertPageWriteAllowed(path, input.writer);
|
|
const current = await readCurrentWithHash(ctx, input.companyId, path, space);
|
|
assertExpectedHash(input.expectedHash, current.hash, path);
|
|
await ctx.localFolders.writeTextAtomic(input.companyId, WIKI_ROOT_FOLDER_KEY, spaceRelativePath(space, path), input.contents);
|
|
const metadata = await upsertPageMetadata(ctx, {
|
|
companyId: input.companyId,
|
|
wikiId,
|
|
spaceId: space.id,
|
|
path,
|
|
contents: input.contents,
|
|
summary: input.summary,
|
|
sourceRefs: input.sourceRefs,
|
|
operationId: input.operationId,
|
|
});
|
|
await upsertWikiInstance(ctx, { companyId: input.companyId, wikiId });
|
|
return { status: "ok", wikiId, spaceSlug: space.slug, path, previousHash: current.hash, ...metadata };
|
|
}
|
|
|
|
export async function captureWikiSource(ctx: PluginContext, input: CaptureSourceInput) {
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
const space = await resolveSpace(ctx, { companyId: input.companyId, wikiId, spaceSlug: input.spaceSlug });
|
|
const title = stringField(input.title) ?? "Untitled source";
|
|
await assertSourceWithinConfiguredLimit(ctx, input.contents);
|
|
const hash = contentHash(input.contents);
|
|
const rawPath = input.rawPath
|
|
? assertRawPath(input.rawPath)
|
|
: assertRawPath(`raw/${new Date().toISOString().slice(0, 10)}-${slugify(title)}-${hash.slice(0, 8)}.md`);
|
|
await ctx.localFolders.writeTextAtomic(input.companyId, WIKI_ROOT_FOLDER_KEY, spaceRelativePath(space, rawPath), input.contents);
|
|
await upsertWikiInstance(ctx, { companyId: input.companyId, wikiId });
|
|
const sourceId = randomUUID();
|
|
await ctx.db.execute(
|
|
`INSERT INTO ${tableName(ctx.db.namespace, "wiki_sources")}
|
|
(id, company_id, wiki_id, space_id, source_type, title, url, raw_path, content_hash, status, metadata)
|
|
VALUES ($1, $2, $3, $10, $4, $5, $6, $7, $8, 'captured', $9::jsonb)`,
|
|
[
|
|
sourceId,
|
|
input.companyId,
|
|
wikiId,
|
|
stringField(input.sourceType) ?? "text",
|
|
title,
|
|
stringField(input.url),
|
|
rawPath,
|
|
hash,
|
|
jsonParam(input.metadata ?? {}),
|
|
space.id,
|
|
],
|
|
);
|
|
return { status: "ok", sourceId, wikiId, spaceSlug: space.slug, rawPath, hash, title };
|
|
}
|
|
|
|
export async function getOverview(ctx: PluginContext, companyId: string): Promise<WikiOverview> {
|
|
const [defaultSpace, folder, managedAgent, managedProject, managedSkills] = await Promise.all([
|
|
ensureDefaultSpace(ctx, { companyId, wikiId: DEFAULT_WIKI_ID }),
|
|
ctx.localFolders.status(companyId, WIKI_ROOT_FOLDER_KEY),
|
|
resolveWikiAgentResource(ctx, companyId),
|
|
resolveWikiProjectResource(ctx, companyId),
|
|
resolveWikiSkillResources(ctx, companyId),
|
|
]);
|
|
const operationRows = await ctx.db.query<{ count: string }>(
|
|
`SELECT count(*)::text AS count FROM ${tableName(ctx.db.namespace, "wiki_operations")} WHERE company_id = $1`,
|
|
[companyId],
|
|
);
|
|
const operationCount = Number(operationRows[0]?.count ?? 0);
|
|
const eventIngestion = await getEventIngestionSettings(ctx, companyId);
|
|
return {
|
|
status: "ok",
|
|
checkedAt: new Date().toISOString(),
|
|
wikiId: DEFAULT_WIKI_ID,
|
|
defaultSpace,
|
|
folder,
|
|
managedAgent,
|
|
managedProject,
|
|
managedSkills,
|
|
operationCount,
|
|
eventIngestion,
|
|
capabilities: ctx.manifest.capabilities,
|
|
prompts: {
|
|
query: QUERY_PROMPT,
|
|
lint: LINT_PROMPT,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function resolveWikiAgentResource(
|
|
ctx: PluginContext,
|
|
companyId: string,
|
|
options: { reconcileMissing?: boolean } = {},
|
|
): Promise<WikiAgentResource> {
|
|
const wikiId = DEFAULT_WIKI_ID;
|
|
const binding = await getResourceBinding(ctx, {
|
|
companyId,
|
|
wikiId,
|
|
resourceKind: "agent",
|
|
resourceKey: WIKI_MAINTAINER_AGENT_KEY,
|
|
});
|
|
const selectedAgent = await resolveSelectedAgent(ctx, companyId, binding);
|
|
if (selectedAgent) {
|
|
const source = binding?.metadata.source === "managed-default" ? "managed" : "selected";
|
|
const managedResolution = source === "managed"
|
|
? await ctx.agents.managed.get(WIKI_MAINTAINER_AGENT_KEY, companyId)
|
|
: null;
|
|
return agentResource({
|
|
status: "resolved",
|
|
source,
|
|
agent: selectedAgent,
|
|
defaultDrift: managedResolution?.defaultDrift ?? null,
|
|
});
|
|
}
|
|
if (binding?.resolvedId && !options.reconcileMissing) {
|
|
return agentResource({ status: "missing", source: "selected", agent: null });
|
|
}
|
|
|
|
const resolved = options.reconcileMissing
|
|
? await ctx.agents.managed.reconcile(WIKI_MAINTAINER_AGENT_KEY, companyId)
|
|
: await ctx.agents.managed.get(WIKI_MAINTAINER_AGENT_KEY, companyId);
|
|
if (resolved.agentId && options.reconcileMissing) {
|
|
await upsertResourceBinding(ctx, {
|
|
companyId,
|
|
wikiId,
|
|
resourceKind: "agent",
|
|
resourceKey: WIKI_MAINTAINER_AGENT_KEY,
|
|
resolvedId: resolved.agentId,
|
|
metadata: { source: "managed-default", updatedBy: "resolve" },
|
|
});
|
|
}
|
|
return agentResource({ status: resolved.status, source: "managed", agent: resolved.agent, defaultDrift: resolved.defaultDrift ?? null });
|
|
}
|
|
|
|
export async function resolveWikiProjectResource(
|
|
ctx: PluginContext,
|
|
companyId: string,
|
|
options: { reconcileMissing?: boolean } = {},
|
|
): Promise<WikiProjectResource> {
|
|
const wikiId = DEFAULT_WIKI_ID;
|
|
const binding = await getResourceBinding(ctx, {
|
|
companyId,
|
|
wikiId,
|
|
resourceKind: "project",
|
|
resourceKey: WIKI_PROJECT_KEY,
|
|
});
|
|
const selectedProject = await resolveSelectedProject(ctx, companyId, binding);
|
|
if (selectedProject) {
|
|
return projectResource({
|
|
status: "resolved",
|
|
source: binding?.metadata.source === "managed-default" ? "managed" : "selected",
|
|
project: selectedProject,
|
|
});
|
|
}
|
|
if (binding?.resolvedId && !options.reconcileMissing) {
|
|
return projectResource({ status: "missing", source: "selected", project: null });
|
|
}
|
|
|
|
const resolved = options.reconcileMissing
|
|
? await ctx.projects.managed.reconcile(WIKI_PROJECT_KEY, companyId)
|
|
: await ctx.projects.managed.get(WIKI_PROJECT_KEY, companyId);
|
|
if (resolved.projectId && options.reconcileMissing) {
|
|
await upsertResourceBinding(ctx, {
|
|
companyId,
|
|
wikiId,
|
|
resourceKind: "project",
|
|
resourceKey: WIKI_PROJECT_KEY,
|
|
resolvedId: resolved.projectId,
|
|
metadata: { source: "managed-default", updatedBy: "resolve" },
|
|
});
|
|
}
|
|
return projectResource({ status: resolved.status, source: "managed", project: resolved.project });
|
|
}
|
|
|
|
export async function resolveWikiSkillResources(
|
|
ctx: PluginContext,
|
|
companyId: string,
|
|
options: { reconcileMissing?: boolean } = {},
|
|
): Promise<WikiSkillResource[]> {
|
|
return Promise.all(
|
|
WIKI_MANAGED_SKILL_KEYS.map(async (skillKey) => {
|
|
const resolved = options.reconcileMissing
|
|
? await ctx.skills.managed.reconcile(skillKey, companyId)
|
|
: await ctx.skills.managed.get(skillKey, companyId);
|
|
return skillResource(resolved);
|
|
}),
|
|
);
|
|
}
|
|
|
|
export async function reconcileWikiAgentResource(ctx: PluginContext, companyId: string): Promise<WikiAgentResource> {
|
|
const resolved = await ctx.agents.managed.reconcile(WIKI_MAINTAINER_AGENT_KEY, companyId);
|
|
if (resolved.agentId) {
|
|
await upsertResourceBinding(ctx, {
|
|
companyId,
|
|
wikiId: DEFAULT_WIKI_ID,
|
|
resourceKind: "agent",
|
|
resourceKey: WIKI_MAINTAINER_AGENT_KEY,
|
|
resolvedId: resolved.agentId,
|
|
metadata: { source: "managed-default", updatedBy: "reconcile" },
|
|
});
|
|
}
|
|
return agentResource({ status: resolved.status, source: "managed", agent: resolved.agent, defaultDrift: resolved.defaultDrift ?? null });
|
|
}
|
|
|
|
export async function resetWikiAgentResource(ctx: PluginContext, companyId: string): Promise<WikiAgentResource> {
|
|
const resolved = await ctx.agents.managed.reset(WIKI_MAINTAINER_AGENT_KEY, companyId);
|
|
if (resolved.agentId) {
|
|
await upsertResourceBinding(ctx, {
|
|
companyId,
|
|
wikiId: DEFAULT_WIKI_ID,
|
|
resourceKind: "agent",
|
|
resourceKey: WIKI_MAINTAINER_AGENT_KEY,
|
|
resolvedId: resolved.agentId,
|
|
metadata: { source: "managed-default", updatedBy: "reset" },
|
|
});
|
|
}
|
|
return agentResource({ status: resolved.status, source: "managed", agent: resolved.agent, defaultDrift: resolved.defaultDrift ?? null });
|
|
}
|
|
|
|
export async function selectWikiAgentResource(ctx: PluginContext, input: { companyId: string; agentId: string }): Promise<WikiAgentResource> {
|
|
const agent = await ctx.agents.get(input.agentId, input.companyId);
|
|
if (!agent || agent.status === "terminated") {
|
|
throw new Error("Selected Wiki Maintainer agent was not found or is terminated.");
|
|
}
|
|
await upsertResourceBinding(ctx, {
|
|
companyId: input.companyId,
|
|
wikiId: DEFAULT_WIKI_ID,
|
|
resourceKind: "agent",
|
|
resourceKey: WIKI_MAINTAINER_AGENT_KEY,
|
|
resolvedId: agent.id,
|
|
metadata: { source: "selected-existing", updatedBy: "settings" },
|
|
});
|
|
return agentResource({ status: "resolved", source: "selected", agent });
|
|
}
|
|
|
|
export async function reconcileWikiProjectResource(ctx: PluginContext, companyId: string): Promise<WikiProjectResource> {
|
|
const resolved = await ctx.projects.managed.reconcile(WIKI_PROJECT_KEY, companyId);
|
|
if (resolved.projectId) {
|
|
await upsertResourceBinding(ctx, {
|
|
companyId,
|
|
wikiId: DEFAULT_WIKI_ID,
|
|
resourceKind: "project",
|
|
resourceKey: WIKI_PROJECT_KEY,
|
|
resolvedId: resolved.projectId,
|
|
metadata: { source: "managed-default", updatedBy: "reconcile" },
|
|
});
|
|
}
|
|
return projectResource({ status: resolved.status, source: "managed", project: resolved.project });
|
|
}
|
|
|
|
export async function resetWikiProjectResource(ctx: PluginContext, companyId: string): Promise<WikiProjectResource> {
|
|
const resolved = await ctx.projects.managed.reset(WIKI_PROJECT_KEY, companyId);
|
|
if (resolved.projectId) {
|
|
await upsertResourceBinding(ctx, {
|
|
companyId,
|
|
wikiId: DEFAULT_WIKI_ID,
|
|
resourceKind: "project",
|
|
resourceKey: WIKI_PROJECT_KEY,
|
|
resolvedId: resolved.projectId,
|
|
metadata: { source: "managed-default", updatedBy: "reset" },
|
|
});
|
|
}
|
|
return projectResource({ status: resolved.status, source: "managed", project: resolved.project });
|
|
}
|
|
|
|
export async function reconcileWikiSkillResources(ctx: PluginContext, companyId: string): Promise<WikiSkillResource[]> {
|
|
return resolveWikiSkillResources(ctx, companyId, { reconcileMissing: true });
|
|
}
|
|
|
|
export async function resetWikiSkillResources(ctx: PluginContext, companyId: string): Promise<WikiSkillResource[]> {
|
|
return Promise.all(
|
|
WIKI_MANAGED_SKILL_KEYS.map(async (skillKey) => {
|
|
return skillResource(await ctx.skills.managed.reset(skillKey, companyId));
|
|
}),
|
|
);
|
|
}
|
|
|
|
export async function reconcileWikiRoutineResources(
|
|
ctx: PluginContext,
|
|
companyId: string,
|
|
): Promise<{
|
|
managedAgent: WikiAgentResource;
|
|
managedProject: WikiProjectResource;
|
|
managedRoutines: PluginManagedRoutineResolution[];
|
|
}> {
|
|
const [managedAgent, managedProject] = await Promise.all([
|
|
reconcileWikiAgentResource(ctx, companyId),
|
|
reconcileWikiProjectResource(ctx, companyId),
|
|
]);
|
|
|
|
const managedRoutines = await Promise.all(
|
|
WIKI_MAINTENANCE_ROUTINE_KEYS.map((routineKey) =>
|
|
ctx.routines.managed.reconcile(routineKey, companyId, {
|
|
assigneeAgentId: managedAgent.agentId,
|
|
projectId: managedProject.projectId,
|
|
})),
|
|
);
|
|
|
|
return { managedAgent, managedProject, managedRoutines };
|
|
}
|
|
|
|
export async function selectWikiProjectResource(ctx: PluginContext, input: { companyId: string; projectId: string }): Promise<WikiProjectResource> {
|
|
const project = await ctx.projects.get(input.projectId, input.companyId);
|
|
if (!project) {
|
|
throw new Error("Selected LLM Wiki project was not found.");
|
|
}
|
|
await upsertResourceBinding(ctx, {
|
|
companyId: input.companyId,
|
|
wikiId: DEFAULT_WIKI_ID,
|
|
resourceKind: "project",
|
|
resourceKey: WIKI_PROJECT_KEY,
|
|
resolvedId: project.id,
|
|
metadata: { source: "selected-existing", updatedBy: "settings" },
|
|
});
|
|
return projectResource({ status: "resolved", source: "selected", project });
|
|
}
|
|
|
|
export async function listWikiAgentOptions(ctx: PluginContext, companyId: string): Promise<WikiResourceOption[]> {
|
|
const agents = await ctx.agents.list({ companyId, limit: 200 });
|
|
return agents
|
|
.filter((agent) => agent.status !== "terminated")
|
|
.map((agent) => ({
|
|
id: agent.id,
|
|
name: agent.name,
|
|
status: agent.status,
|
|
adapterType: agent.adapterType ?? null,
|
|
icon: agent.icon ?? null,
|
|
urlKey: agent.urlKey ?? null,
|
|
}));
|
|
}
|
|
|
|
export async function listWikiProjectOptions(ctx: PluginContext, companyId: string): Promise<WikiResourceOption[]> {
|
|
const projects = await ctx.projects.list({ companyId, limit: 200 });
|
|
return projects.map((project) => ({ id: project.id, name: project.name, status: project.status, color: project.color ?? null }));
|
|
}
|
|
|
|
export async function bootstrapWikiRoot(ctx: PluginContext, input: BootstrapInput) {
|
|
const wikiId = DEFAULT_WIKI_ID;
|
|
const defaultSpace = await ensureDefaultSpace(ctx, { companyId: input.companyId, wikiId });
|
|
const configureFolder = (path: string) => ctx.localFolders.configure({
|
|
companyId: input.companyId,
|
|
folderKey: WIKI_ROOT_FOLDER_KEY,
|
|
path,
|
|
access: "readWrite",
|
|
requiredDirectories: [...REQUIRED_WIKI_DIRECTORIES],
|
|
requiredFiles: [...REQUIRED_WIKI_FILES],
|
|
});
|
|
const currentFolder = input.path
|
|
? null
|
|
: await ctx.localFolders.status(input.companyId, WIKI_ROOT_FOLDER_KEY);
|
|
const folder = input.path
|
|
? await configureFolder(input.path)
|
|
: currentFolder?.configured && currentFolder.path
|
|
? await configureFolder(currentFolder.path)
|
|
: currentFolder ?? await ctx.localFolders.status(input.companyId, WIKI_ROOT_FOLDER_KEY);
|
|
|
|
const writtenFiles: string[] = [];
|
|
const preservedFiles: string[] = [];
|
|
for (const file of BOOTSTRAP_FILES) {
|
|
const path = assertWikiPath(file.path, { allowMetadata: true });
|
|
try {
|
|
await ctx.localFolders.readText(input.companyId, WIKI_ROOT_FOLDER_KEY, path);
|
|
preservedFiles.push(path);
|
|
continue;
|
|
} catch {
|
|
// Missing files are initialized below. Existing files are intentionally preserved.
|
|
}
|
|
await ctx.localFolders.writeTextAtomic(input.companyId, WIKI_ROOT_FOLDER_KEY, file.path, file.contents);
|
|
writtenFiles.push(path);
|
|
}
|
|
|
|
await upsertWikiInstance(ctx, { companyId: input.companyId, wikiId, rootPath: folder.path });
|
|
const managedSkills = await reconcileWikiSkillResources(ctx, input.companyId);
|
|
const [managedAgent, managedProject] = await Promise.all([
|
|
reconcileWikiAgentResource(ctx, input.companyId),
|
|
reconcileWikiProjectResource(ctx, input.companyId),
|
|
]);
|
|
await ctx.state.set(
|
|
{
|
|
scopeKind: "company",
|
|
scopeId: input.companyId,
|
|
namespace: "llm-wiki",
|
|
stateKey: "last-bootstrap",
|
|
},
|
|
{ at: new Date().toISOString(), path: folder.path },
|
|
);
|
|
|
|
return {
|
|
status: "ok",
|
|
folder,
|
|
wikiId,
|
|
space: defaultSpace,
|
|
managedAgent,
|
|
managedProject,
|
|
managedSkills,
|
|
writtenFiles,
|
|
preservedFiles,
|
|
};
|
|
}
|
|
|
|
export async function bootstrapSpace(ctx: PluginContext, input: SpaceInput) {
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
const space = await resolveSpace(ctx, { companyId: input.companyId, wikiId, spaceSlug: input.spaceSlug });
|
|
const { writtenFiles, preservedFiles } = await bootstrapSpaceFiles(ctx, input.companyId, space);
|
|
await upsertWikiInstance(ctx, { companyId: input.companyId, wikiId });
|
|
return {
|
|
status: "ok",
|
|
wikiId,
|
|
space,
|
|
writtenFiles,
|
|
preservedFiles,
|
|
};
|
|
}
|
|
|
|
async function bootstrapSpaceFiles(ctx: PluginContext, companyId: string, space: WikiSpace) {
|
|
const writtenFiles: string[] = [];
|
|
const preservedFiles: string[] = [];
|
|
for (const file of BOOTSTRAP_FILES) {
|
|
const path = assertWikiPath(file.path, { allowMetadata: true });
|
|
const physicalPath = spaceRelativePath(space, path);
|
|
try {
|
|
await ctx.localFolders.readText(companyId, WIKI_ROOT_FOLDER_KEY, physicalPath);
|
|
preservedFiles.push(path);
|
|
continue;
|
|
} catch {
|
|
// Missing files are initialized below. Existing files are intentionally preserved.
|
|
}
|
|
await ctx.localFolders.writeTextAtomic(companyId, WIKI_ROOT_FOLDER_KEY, physicalPath, file.contents);
|
|
writtenFiles.push(path);
|
|
}
|
|
return { writtenFiles, preservedFiles };
|
|
}
|
|
|
|
function operationSpaceRoot(space: WikiSpace): string {
|
|
return space.pathPrefix ? `${space.rootFolderKey}/${space.pathPrefix}` : `${space.rootFolderKey} root`;
|
|
}
|
|
|
|
function operationBillingContext(wikiId: string, space: WikiSpace): string {
|
|
return space.slug === DEFAULT_SPACE_SLUG
|
|
? `plugin-llm-wiki:${wikiId} (space ${space.slug})`
|
|
: `plugin-llm-wiki:${wikiId}:${space.slug}`;
|
|
}
|
|
|
|
function operationBillingCode(wikiId: string, space: WikiSpace): string {
|
|
return space.slug === DEFAULT_SPACE_SLUG ? `plugin-llm-wiki:${wikiId}` : `plugin-llm-wiki:${wikiId}:${space.slug}`;
|
|
}
|
|
|
|
function operationIssueOriginId(input: { wikiId: string; space: WikiSpace; operationId: string }): string {
|
|
return input.space.slug === DEFAULT_SPACE_SLUG
|
|
? `wiki:${input.wikiId}:operation:${input.operationId}`
|
|
: `wiki:${input.wikiId}:space:${input.space.slug}:operation:${input.operationId}`;
|
|
}
|
|
|
|
function operationTitleWithSpace(title: string, space: WikiSpace): string {
|
|
return `${title} [space: ${space.displayName} / ${space.slug}]`;
|
|
}
|
|
|
|
function operationPromptWithSpaceContext(input: OperationSpaceContext): string {
|
|
const paperclipDerived = input.operationType === "distill" || input.operationType === "backfill";
|
|
return [
|
|
`Plugin operation: ${input.operationType}`,
|
|
`Wiki ID: ${input.wikiId}`,
|
|
`Space: ${input.space.displayName} (${input.space.slug})`,
|
|
`Space root: ${operationSpaceRoot(input.space)}`,
|
|
`Billing context: ${operationBillingContext(input.wikiId, input.space)}`,
|
|
"",
|
|
"Space isolation requirement:",
|
|
`- Pass wikiId \`${input.wikiId}\` and spaceSlug \`${input.space.slug}\` on every LLM Wiki tool call.`,
|
|
"- Treat all paths in the prompt as relative to this space root.",
|
|
paperclipDerived
|
|
? "- Paperclip-derived distill/backfill operations are default-space-only in Phase 1. Stop and comment if asked to write Paperclip-derived pages into a non-default space."
|
|
: "- Manual ingest, query, lint, index, and file-as-page operations follow the named destination space. Do not cross into another space unless the operation explicitly asks for a multi-space sweep.",
|
|
"",
|
|
input.prompt ?? "Created by the LLM Wiki plugin.",
|
|
].join("\n");
|
|
}
|
|
|
|
function operationMetadata(input: OperationSpaceContext) {
|
|
return {
|
|
operationType: input.operationType,
|
|
operationId: input.operationId,
|
|
wikiId: input.wikiId,
|
|
spaceId: input.space.id,
|
|
spaceSlug: input.space.slug,
|
|
spaceName: input.space.displayName,
|
|
spaceRootFolderKey: input.space.rootFolderKey,
|
|
spacePathPrefix: input.space.pathPrefix,
|
|
spaceRoot: operationSpaceRoot(input.space),
|
|
};
|
|
}
|
|
|
|
export async function createOperationIssue(ctx: PluginContext, input: OperationInput) {
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
const space = input.operationType === "distill" || input.operationType === "backfill"
|
|
? await requirePaperclipIngestionPolicy(ctx, { companyId: input.companyId, wikiId, spaceSlug: input.spaceSlug }, "queue", { requireEnabledProfile: true })
|
|
: await resolveSpace(ctx, { companyId: input.companyId, wikiId, spaceSlug: input.spaceSlug });
|
|
const managedAgent = await resolveWikiAgentResource(ctx, input.companyId, { reconcileMissing: true });
|
|
const managedProject = await resolveWikiProjectResource(ctx, input.companyId, { reconcileMissing: true });
|
|
const operationId = randomUUID();
|
|
const title = operationTitleWithSpace(input.title ?? `LLM Wiki ${input.operationType}`, space);
|
|
const originId = operationIssueOriginId({ wikiId, space, operationId });
|
|
const operationContext = { wikiId, space, operationType: input.operationType, operationId, prompt: input.prompt };
|
|
const assignableAgentId =
|
|
managedAgent.agent &&
|
|
managedAgent.agent.status !== "pending_approval" &&
|
|
managedAgent.agent.status !== "terminated"
|
|
? managedAgent.agent.id
|
|
: undefined;
|
|
const issue = await ctx.issues.create({
|
|
companyId: input.companyId,
|
|
projectId: managedProject.projectId ?? undefined,
|
|
title,
|
|
description: operationPromptWithSpaceContext(operationContext),
|
|
status: "todo",
|
|
priority: input.operationType === "query" ? "medium" : "low",
|
|
assigneeAgentId: assignableAgentId,
|
|
assigneeAdapterOverrides: input.useCheapModelProfile ? { modelProfile: "cheap" } : null,
|
|
billingCode: operationBillingCode(wikiId, space),
|
|
surfaceVisibility: "plugin_operation",
|
|
originKind: `${OPERATION_ORIGIN_KIND}:${input.operationType}` as PluginIssueOriginKind,
|
|
originId,
|
|
});
|
|
|
|
await ctx.db.execute(
|
|
`INSERT INTO ${tableName(ctx.db.namespace, "wiki_operations")}
|
|
(id, company_id, wiki_id, space_id, operation_type, status, hidden_issue_id, project_id, run_ids, cost_cents, warnings, metadata)
|
|
VALUES ($1, $2, $3, $8, $4, $5, $6, $7, '[]'::jsonb, 0, '[]'::jsonb, $9::jsonb)`,
|
|
[
|
|
operationId,
|
|
input.companyId,
|
|
wikiId,
|
|
input.operationType,
|
|
"queued",
|
|
issue.id,
|
|
issue.projectId ?? null,
|
|
space.id,
|
|
jsonParam({
|
|
...operationMetadata(operationContext),
|
|
issueOriginId: originId,
|
|
billingCode: operationBillingCode(wikiId, space),
|
|
}),
|
|
],
|
|
);
|
|
|
|
return { operationId, wikiId, spaceSlug: space.slug, issue };
|
|
}
|
|
|
|
function isLlmWikiOperationIssue(issue: Issue): boolean {
|
|
return typeof issue.originKind === "string" && issue.originKind.startsWith(OPERATION_ORIGIN_KIND);
|
|
}
|
|
|
|
function paperclipDistillationScope(input: { projectId?: string | null; rootIssueId?: string | null }): PaperclipDistillationScope {
|
|
if (input.rootIssueId) return "root_issue";
|
|
if (input.projectId) return "project";
|
|
return "company";
|
|
}
|
|
|
|
function paperclipCursorScopeMetadata(input: { projectId?: string | null; rootIssueId?: string | null }) {
|
|
const sourceScope = paperclipDistillationScope(input);
|
|
const projectId = sourceScope === "project" ? input.projectId ?? null : null;
|
|
const rootIssueId = sourceScope === "root_issue" ? input.rootIssueId ?? null : null;
|
|
return {
|
|
sourceScope,
|
|
scopeKey: rootIssueId ?? projectId ?? "company",
|
|
projectId,
|
|
rootIssueId,
|
|
};
|
|
}
|
|
|
|
async function upsertPaperclipDistillationCursor(ctx: PluginContext, input: {
|
|
companyId: string;
|
|
wikiId: string;
|
|
spaceId: string;
|
|
projectId?: string | null;
|
|
rootIssueId?: string | null;
|
|
observedAt?: string | null;
|
|
metadata?: Record<string, unknown>;
|
|
}): Promise<string> {
|
|
const cursorId = randomUUID();
|
|
const scope = paperclipCursorScopeMetadata(input);
|
|
await ctx.db.execute(
|
|
`INSERT INTO ${distillationCursorTable(ctx)} AS paperclip_distillation_cursors
|
|
(id, company_id, wiki_id, space_id, source_scope, scope_key, project_id, root_issue_id, source_kind, last_observed_at, pending_event_count, metadata)
|
|
VALUES ($1, $2, $3, $11, $4, $5, $6, $7, 'paperclip_issue_history', $8::timestamptz, $9, $10::jsonb)
|
|
ON CONFLICT (company_id, wiki_id, space_id, source_scope, scope_key, source_kind)
|
|
DO UPDATE SET last_observed_at = GREATEST(
|
|
COALESCE(paperclip_distillation_cursors.last_observed_at, EXCLUDED.last_observed_at),
|
|
COALESCE(EXCLUDED.last_observed_at, paperclip_distillation_cursors.last_observed_at)
|
|
),
|
|
pending_event_count = paperclip_distillation_cursors.pending_event_count + EXCLUDED.pending_event_count,
|
|
metadata = paperclip_distillation_cursors.metadata || EXCLUDED.metadata,
|
|
updated_at = now()`,
|
|
[
|
|
cursorId,
|
|
input.companyId,
|
|
input.wikiId,
|
|
scope.sourceScope,
|
|
scope.scopeKey,
|
|
scope.projectId,
|
|
scope.rootIssueId,
|
|
input.observedAt ?? null,
|
|
input.observedAt ? 1 : 0,
|
|
jsonParam(input.metadata ?? {}),
|
|
input.spaceId,
|
|
],
|
|
);
|
|
const rows = await ctx.db.query<{ id: string }>(
|
|
`SELECT id
|
|
FROM ${distillationCursorTable(ctx)}
|
|
WHERE company_id = $1
|
|
AND wiki_id = $2
|
|
AND space_id = $3
|
|
AND source_scope = $4
|
|
AND scope_key = $5
|
|
AND source_kind = 'paperclip_issue_history'
|
|
LIMIT 1`,
|
|
[input.companyId, input.wikiId, input.spaceId, scope.sourceScope, scope.scopeKey],
|
|
);
|
|
return rows[0]?.id ?? cursorId;
|
|
}
|
|
|
|
function isActiveDistillationProject(project: Project): boolean {
|
|
if (project.status !== "in_progress") return false;
|
|
if (project.archivedAt) return false;
|
|
if (project.managedByPlugin?.pluginKey === PLUGIN_ID) return false;
|
|
if (project.managedByPlugin?.resourceKey === WIKI_PROJECT_KEY) return false;
|
|
return true;
|
|
}
|
|
|
|
function projectActivityTimestamp(project: Project): string {
|
|
return isoString(project.updatedAt) ?? new Date().toISOString();
|
|
}
|
|
|
|
export async function enableActiveProjectDistillation(ctx: PluginContext, input: {
|
|
companyId: string;
|
|
wikiId?: string | null;
|
|
spaceSlug?: string | null;
|
|
limit?: number | null;
|
|
}): Promise<EnableActiveProjectDistillationResult> {
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
const space = await requirePaperclipIngestionPolicy(ctx, { companyId: input.companyId, wikiId, spaceSlug: input.spaceSlug }, "candidate_search", { requireEnabledProfile: true });
|
|
if (typeof input.limit === "number" && Number.isFinite(input.limit) && Math.floor(input.limit) > MAX_PAPERCLIP_DISTILLATION_FAN_OUT) {
|
|
throw new Error(`Paperclip ingestion fan-out exceeds the hard cap of ${MAX_PAPERCLIP_DISTILLATION_FAN_OUT} enabled profiles.`);
|
|
}
|
|
const limit = normalizeLimit(input.limit ?? 3, 3, 25);
|
|
const projects = await ctx.projects.list({ companyId: input.companyId, limit: 200 });
|
|
const activeProjects = projects
|
|
.filter(isActiveDistillationProject)
|
|
.sort((a, b) => projectActivityTimestamp(b).localeCompare(projectActivityTimestamp(a)))
|
|
.slice(0, limit);
|
|
|
|
const selectedProjects: EnableActiveProjectDistillationResult["selectedProjects"] = [];
|
|
for (const project of activeProjects) {
|
|
const observedAt = projectActivityTimestamp(project);
|
|
const cursorId = await upsertPaperclipDistillationCursor(ctx, {
|
|
companyId: input.companyId,
|
|
wikiId,
|
|
spaceId: space.id,
|
|
projectId: project.id,
|
|
rootIssueId: null,
|
|
observedAt,
|
|
metadata: {
|
|
configuredBy: "enable-active-projects",
|
|
projectName: project.name,
|
|
projectStatus: project.status,
|
|
},
|
|
});
|
|
selectedProjects.push({
|
|
id: project.id,
|
|
name: project.name,
|
|
status: project.status,
|
|
observedAt,
|
|
cursorId,
|
|
});
|
|
}
|
|
|
|
const eventIngestion = await updateEventIngestionSettings(ctx, {
|
|
companyId: input.companyId,
|
|
settings: {
|
|
enabled: true,
|
|
wikiId,
|
|
sources: {
|
|
issues: true,
|
|
comments: true,
|
|
documents: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
return {
|
|
wikiId,
|
|
spaceSlug: space.slug,
|
|
selectedProjects,
|
|
eventIngestion,
|
|
};
|
|
}
|
|
|
|
function appendBoundedSection(input: {
|
|
lines: string[];
|
|
title: string;
|
|
body: string;
|
|
refs: PaperclipSourceRef[];
|
|
ref: PaperclipSourceRef;
|
|
remaining: { value: number };
|
|
perSourceLimit: number;
|
|
warnings: string[];
|
|
}) {
|
|
if (input.remaining.value <= 0) {
|
|
input.warnings.push(`Skipped ${input.title}: source bundle character limit reached.`);
|
|
return;
|
|
}
|
|
const boundedBody = input.body.length > input.perSourceLimit
|
|
? `${input.body.slice(0, input.perSourceLimit)}\n\n[Clipped at ${input.perSourceLimit} characters for this source.]`
|
|
: input.body;
|
|
const section = [`## ${input.title}`, "", boundedBody.trim() || "_No content._", ""].join("\n");
|
|
const clippedSection = section.length > input.remaining.value
|
|
? `${section.slice(0, input.remaining.value)}\n\n[Source bundle clipped at configured limit.]\n`
|
|
: section;
|
|
input.lines.push(clippedSection);
|
|
input.refs.push(input.ref);
|
|
if (boundedBody.length !== input.body.length || clippedSection.length !== section.length) {
|
|
input.warnings.push(`Clipped ${input.title}.`);
|
|
}
|
|
input.remaining.value -= clippedSection.length;
|
|
}
|
|
|
|
function issueSortKey(issue: Issue): string {
|
|
return `${issue.identifier ?? ""}:${issue.title}:${issue.id}`;
|
|
}
|
|
|
|
function sourceRefUpdatedAt(ref: PaperclipSourceRef): string | null {
|
|
return ref.updatedAt ?? ref.createdAt ?? null;
|
|
}
|
|
|
|
function issueInBackfillWindow(issue: Issue, input: Pick<PaperclipSourceBundleInput, "backfillStartAt" | "backfillEndAt">): boolean {
|
|
const issueUpdatedAt = isoString(issue.updatedAt);
|
|
if (!issueUpdatedAt) return true;
|
|
const startAt = isoString(input.backfillStartAt);
|
|
const endAt = isoString(input.backfillEndAt);
|
|
if (startAt && issueUpdatedAt < startAt) return false;
|
|
if (endAt && issueUpdatedAt > endAt) return false;
|
|
return true;
|
|
}
|
|
|
|
async function listPaperclipBundleIssues(ctx: PluginContext, input: PaperclipSourceBundleInput): Promise<Issue[]> {
|
|
const filterAndSort = (issues: Issue[]) =>
|
|
issues
|
|
.filter((issue) => !isLlmWikiOperationIssue(issue))
|
|
.filter((issue) => issueInBackfillWindow(issue, input))
|
|
.sort((a, b) => issueSortKey(a).localeCompare(issueSortKey(b)));
|
|
|
|
if (input.rootIssueId) {
|
|
const subtree = await ctx.issues.getSubtree(input.rootIssueId, input.companyId, {
|
|
includeRoot: true,
|
|
includeRelations: true,
|
|
includeDocuments: true,
|
|
includeAssignees: true,
|
|
});
|
|
return filterAndSort(subtree.issues);
|
|
}
|
|
|
|
const issues = await ctx.issues.list({
|
|
companyId: input.companyId,
|
|
projectId: input.projectId ?? undefined,
|
|
includePluginOperations: false,
|
|
limit: 500,
|
|
});
|
|
return filterAndSort(issues);
|
|
}
|
|
|
|
export async function assemblePaperclipSourceBundle(ctx: PluginContext, input: PaperclipSourceBundleInput): Promise<PaperclipSourceBundle> {
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
assertPaperclipSourceScopePayload(input);
|
|
const space = await requirePaperclipIngestionPolicy(ctx, { companyId: input.companyId, wikiId, spaceSlug: input.spaceSlug }, "execute", { requireEnabledProfile: true });
|
|
const limits = await resolvePaperclipDistillationLimitsForSpace(ctx, { ...input, space });
|
|
const maxCharacters = limits.maxCharacters;
|
|
const perSourceLimit = limits.maxCharactersPerSource;
|
|
const includeComments = input.includeComments !== false;
|
|
const includeDocuments = input.includeDocuments !== false;
|
|
const issues = await listPaperclipBundleIssues(ctx, input);
|
|
const scope = paperclipCursorScopeMetadata(input);
|
|
const sourceRefs: PaperclipSourceRef[] = [];
|
|
const warnings: string[] = [];
|
|
const lines = [
|
|
`# Paperclip source bundle`,
|
|
"",
|
|
"## Bundle Metadata",
|
|
"",
|
|
`- Company ID: ${input.companyId}`,
|
|
`- Wiki ID: ${wikiId}`,
|
|
`- Space: ${space.displayName} (${space.slug})`,
|
|
`- Source scope: ${scope.sourceScope}`,
|
|
scope.projectId ? `- Project ID: ${scope.projectId}` : null,
|
|
scope.rootIssueId ? `- Root issue ID: ${scope.rootIssueId}` : null,
|
|
input.backfillStartAt ? `- Backfill start: ${isoString(input.backfillStartAt) ?? input.backfillStartAt}` : null,
|
|
input.backfillEndAt ? `- Backfill end: ${isoString(input.backfillEndAt) ?? input.backfillEndAt}` : null,
|
|
`- Issue count: ${issues.length}`,
|
|
`- Source caps: ${maxCharacters} characters per window; ${perSourceLimit} characters per source`,
|
|
"",
|
|
].filter((line): line is string => line !== null);
|
|
const remaining = { value: maxCharacters - lines.join("\n").length };
|
|
|
|
for (const issue of issues) {
|
|
const issueBody = [
|
|
`- Issue ID: ${issue.id}`,
|
|
issue.identifier ? `- Identifier: ${issue.identifier}` : null,
|
|
`- Status: ${issue.status}`,
|
|
`- Priority: ${issue.priority}`,
|
|
issue.parentId ? `- Parent issue ID: ${issue.parentId}` : null,
|
|
issue.projectId ? `- Project ID: ${issue.projectId}` : null,
|
|
`- Updated at: ${isoString(issue.updatedAt) ?? "unknown"}`,
|
|
"",
|
|
issue.description?.trim() ? issue.description.trim() : "_No issue description._",
|
|
].filter((line): line is string => line !== null).join("\n");
|
|
appendBoundedSection({
|
|
lines,
|
|
title: `Issue: ${sourceTitleForIssue(issue)}`,
|
|
body: issueBody,
|
|
refs: sourceRefs,
|
|
ref: {
|
|
kind: "issue",
|
|
issueId: issue.id,
|
|
issueIdentifier: issue.identifier ?? null,
|
|
projectId: issue.projectId ?? null,
|
|
title: issue.title,
|
|
updatedAt: isoString(issue.updatedAt) ?? undefined,
|
|
},
|
|
remaining,
|
|
perSourceLimit,
|
|
warnings,
|
|
});
|
|
|
|
if (includeDocuments && remaining.value > 0) {
|
|
const documentSummaries = await ctx.issues.documents.list(issue.id, input.companyId);
|
|
for (const summary of [...documentSummaries].sort((a, b) => a.key.localeCompare(b.key))) {
|
|
const document = await ctx.issues.documents.get(issue.id, summary.key, input.companyId);
|
|
if (!document) continue;
|
|
const protectedDocument = protectDistillationSourceBody({
|
|
issue,
|
|
sourceKind: "document",
|
|
sourceId: document.key,
|
|
body: document.body,
|
|
});
|
|
if (protectedDocument.warning) warnings.push(protectedDocument.warning);
|
|
appendBoundedSection({
|
|
lines,
|
|
title: `Document: ${sourceTitleForIssue(issue)} / ${document.key}`,
|
|
body: [
|
|
`- Issue ID: ${issue.id}`,
|
|
issue.identifier ? `- Issue identifier: ${issue.identifier}` : null,
|
|
`- Document ID: ${document.id}`,
|
|
`- Document key: ${document.key}`,
|
|
`- Revision: ${document.latestRevisionNumber}`,
|
|
`- Updated at: ${isoString(document.updatedAt) ?? "unknown"}`,
|
|
"",
|
|
protectedDocument.body,
|
|
].filter((line): line is string => line !== null).join("\n"),
|
|
refs: sourceRefs,
|
|
ref: {
|
|
kind: "document",
|
|
issueId: issue.id,
|
|
issueIdentifier: issue.identifier ?? null,
|
|
projectId: issue.projectId ?? null,
|
|
documentId: document.id,
|
|
documentKey: document.key,
|
|
updatedAt: isoString(document.updatedAt) ?? undefined,
|
|
...protectedDocument.refPatch,
|
|
},
|
|
remaining,
|
|
perSourceLimit,
|
|
warnings,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (includeComments && remaining.value > 0) {
|
|
const comments = await ctx.issues.listComments(issue.id, input.companyId);
|
|
for (const comment of [...comments].sort((a, b) => (isoString(a.createdAt) ?? "").localeCompare(isoString(b.createdAt) ?? ""))) {
|
|
const protectedComment = protectDistillationSourceBody({
|
|
issue,
|
|
sourceKind: "comment",
|
|
sourceId: comment.id,
|
|
body: comment.body,
|
|
});
|
|
if (protectedComment.warning) warnings.push(protectedComment.warning);
|
|
appendBoundedSection({
|
|
lines,
|
|
title: `Comment: ${sourceTitleForIssue(issue)} / ${comment.id}`,
|
|
body: [
|
|
`- Issue ID: ${issue.id}`,
|
|
issue.identifier ? `- Issue identifier: ${issue.identifier}` : null,
|
|
`- Comment ID: ${comment.id}`,
|
|
`- Created at: ${isoString(comment.createdAt) ?? "unknown"}`,
|
|
"",
|
|
protectedComment.body,
|
|
].filter((line): line is string => line !== null).join("\n"),
|
|
refs: sourceRefs,
|
|
ref: {
|
|
kind: "comment",
|
|
issueId: issue.id,
|
|
issueIdentifier: issue.identifier ?? null,
|
|
projectId: issue.projectId ?? null,
|
|
commentId: comment.id,
|
|
createdAt: isoString(comment.createdAt) ?? undefined,
|
|
...protectedComment.refPatch,
|
|
},
|
|
remaining,
|
|
perSourceLimit,
|
|
warnings,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const markdown = lines.join("\n").slice(0, maxCharacters);
|
|
const sourceDates = sourceRefs.map(sourceRefUpdatedAt).filter((date): date is string => Boolean(date)).sort();
|
|
return {
|
|
markdown,
|
|
sourceRefs,
|
|
sourceHash: contentHash(markdown),
|
|
sourceWindowStart: sourceDates[0] ?? null,
|
|
sourceWindowEnd: sourceDates[sourceDates.length - 1] ?? null,
|
|
clipped: warnings.some((warning) => warning.includes("Clipped") || warning.includes("Skipped")) || lines.join("\n").length > maxCharacters,
|
|
warnings,
|
|
};
|
|
}
|
|
|
|
export async function createPaperclipDistillationRun(ctx: PluginContext, input: PaperclipDistillationRunInput) {
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
assertPaperclipSourceScopePayload(input);
|
|
const space = await requirePaperclipIngestionPolicy(ctx, { companyId: input.companyId, wikiId, spaceSlug: input.spaceSlug }, "execute", { requireEnabledProfile: true });
|
|
const scope = paperclipCursorScopeMetadata(input);
|
|
const limits = await resolvePaperclipDistillationLimitsForSpace(ctx, { ...input, space });
|
|
const cursorId = await upsertPaperclipDistillationCursor(ctx, {
|
|
companyId: input.companyId,
|
|
wikiId,
|
|
spaceId: space.id,
|
|
projectId: scope.projectId,
|
|
rootIssueId: scope.rootIssueId,
|
|
metadata: { source: "source-bundle" },
|
|
});
|
|
const bundle = await assemblePaperclipSourceBundle(ctx, input);
|
|
const estimatedCostCents = estimateSourceCostCents(
|
|
bundle.markdown.length,
|
|
limits.costCentsPerThousandSourceCharacters,
|
|
);
|
|
const runId = randomUUID();
|
|
const snapshotId = randomUUID();
|
|
|
|
await ctx.db.execute(
|
|
`INSERT INTO ${distillationRunTable(ctx)}
|
|
(id, company_id, wiki_id, space_id, cursor_id, work_item_id, project_id, root_issue_id, source_window_start, source_window_end, source_hash, status, operation_issue_id, retry_count, cost_cents, warnings, metadata)
|
|
VALUES ($1, $2, $3, $15, $4, $5, $6, $7, $8::timestamptz, $9::timestamptz, $10, 'source_ready', $11, 0, $12, $13::jsonb, $14::jsonb)`,
|
|
[
|
|
runId,
|
|
input.companyId,
|
|
wikiId,
|
|
cursorId,
|
|
input.workItemId ?? null,
|
|
scope.projectId,
|
|
scope.rootIssueId,
|
|
bundle.sourceWindowStart,
|
|
bundle.sourceWindowEnd,
|
|
bundle.sourceHash,
|
|
input.operationIssueId ?? null,
|
|
estimatedCostCents,
|
|
jsonArrayParam(bundle.warnings),
|
|
jsonParam({
|
|
spaceSlug: space.slug,
|
|
sourceScope: scope.sourceScope,
|
|
limits,
|
|
backfillStartAt: isoString(input.backfillStartAt),
|
|
backfillEndAt: isoString(input.backfillEndAt),
|
|
}),
|
|
space.id,
|
|
],
|
|
);
|
|
await ctx.db.execute(
|
|
`INSERT INTO ${sourceSnapshotTable(ctx)}
|
|
(id, company_id, wiki_id, space_id, distillation_run_id, project_id, root_issue_id, source_hash, max_characters, clipped, source_refs, bundle_markdown, metadata)
|
|
VALUES ($1, $2, $3, $13, $4, $5, $6, $7, $8, $9, $10::jsonb, $11, $12::jsonb)`,
|
|
[
|
|
snapshotId,
|
|
input.companyId,
|
|
wikiId,
|
|
runId,
|
|
scope.projectId,
|
|
scope.rootIssueId,
|
|
bundle.sourceHash,
|
|
limits.maxCharacters,
|
|
bundle.clipped,
|
|
jsonParam(bundle.sourceRefs),
|
|
bundle.markdown,
|
|
jsonParam({
|
|
spaceSlug: space.slug,
|
|
sourceScope: scope.sourceScope,
|
|
estimatedCostCents,
|
|
backfillStartAt: isoString(input.backfillStartAt),
|
|
backfillEndAt: isoString(input.backfillEndAt),
|
|
}),
|
|
space.id,
|
|
],
|
|
);
|
|
|
|
return { status: "source_ready" as const, wikiId, spaceSlug: space.slug, cursorId, runId, snapshotId, bundle, estimatedCostCents };
|
|
}
|
|
|
|
export async function recordPaperclipDistillationOutcome(ctx: PluginContext, input: PaperclipDistillationOutcomeInput) {
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
const space = await requirePaperclipIngestionPolicy(ctx, { companyId: input.companyId, wikiId, spaceSlug: input.spaceSlug }, "execute", { requireEnabledProfile: true });
|
|
const warnings = input.warning ? [input.warning] : [];
|
|
await ctx.db.execute(
|
|
`UPDATE ${distillationRunTable(ctx)}
|
|
SET status = $4,
|
|
warnings = CASE WHEN $5::jsonb = '[]'::jsonb THEN warnings ELSE warnings || $5::jsonb END,
|
|
cost_cents = CASE WHEN $6::integer IS NULL THEN cost_cents ELSE $6::integer END,
|
|
retry_count = CASE WHEN $7::integer IS NULL THEN retry_count ELSE $7::integer END,
|
|
updated_at = now()
|
|
WHERE company_id = $1
|
|
AND wiki_id = $2
|
|
AND space_id = $8
|
|
AND id = $3`,
|
|
[
|
|
input.companyId,
|
|
wikiId,
|
|
input.runId,
|
|
input.status,
|
|
jsonArrayParam(warnings),
|
|
input.costCents ?? null,
|
|
input.retryCount ?? null,
|
|
space.id,
|
|
],
|
|
);
|
|
|
|
if (input.status === "succeeded" && input.cursorId && input.sourceHash && input.sourceWindowEnd) {
|
|
await ctx.db.execute(
|
|
`UPDATE ${distillationCursorTable(ctx)}
|
|
SET last_processed_at = $4::timestamptz,
|
|
last_successful_run_id = $3,
|
|
last_source_hash = $5,
|
|
pending_event_count = 0,
|
|
updated_at = now()
|
|
WHERE company_id = $1
|
|
AND wiki_id = $2
|
|
AND space_id = $7
|
|
AND id = $6`,
|
|
[input.companyId, wikiId, input.runId, input.sourceWindowEnd, input.sourceHash, input.cursorId, space.id],
|
|
);
|
|
}
|
|
|
|
return {
|
|
status: input.status,
|
|
cursorAdvanced: input.status === "succeeded" && Boolean(input.cursorId && input.sourceHash && input.sourceWindowEnd),
|
|
};
|
|
}
|
|
|
|
export async function createPaperclipDistillationWorkItem(ctx: PluginContext, input: PaperclipDistillationWorkItemInput) {
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
assertPaperclipSourceScopePayload(input);
|
|
const space = await requirePaperclipIngestionPolicy(ctx, { companyId: input.companyId, wikiId, spaceSlug: input.spaceSlug }, "queue", { requireEnabledProfile: true });
|
|
const itemId = randomUUID();
|
|
const scope = paperclipCursorScopeMetadata(input);
|
|
if (input.kind === "backfill" && !scope.projectId && !scope.rootIssueId) {
|
|
throw new Error("Backfill work items must target a projectId or rootIssueId; whole-company backfill is not allowed.");
|
|
}
|
|
await ctx.db.execute(
|
|
`INSERT INTO ${distillationWorkItemTable(ctx)} AS paperclip_distillation_work_items
|
|
(id, company_id, wiki_id, space_id, work_item_kind, status, priority, project_id, root_issue_id, requested_by_issue_id, idempotency_key, metadata)
|
|
VALUES ($1, $2, $3, $11, $4, 'pending', $5, $6, $7, $8, $9, $10::jsonb)
|
|
ON CONFLICT (company_id, wiki_id, space_id, idempotency_key)
|
|
DO UPDATE SET priority = EXCLUDED.priority,
|
|
metadata = paperclip_distillation_work_items.metadata || EXCLUDED.metadata,
|
|
updated_at = now()`,
|
|
[
|
|
itemId,
|
|
input.companyId,
|
|
wikiId,
|
|
input.kind,
|
|
input.priority ?? "medium",
|
|
scope.projectId,
|
|
scope.rootIssueId,
|
|
input.requestedByIssueId ?? null,
|
|
input.idempotencyKey ?? null,
|
|
jsonParam({
|
|
spaceSlug: space.slug,
|
|
sourceScope: scope.sourceScope,
|
|
...(input.metadata ?? {}),
|
|
}),
|
|
space.id,
|
|
],
|
|
);
|
|
return { status: "pending", workItemId: itemId, wikiId, spaceSlug: space.slug, kind: input.kind, sourceScope: scope.sourceScope };
|
|
}
|
|
|
|
function sourceRefLabel(ref: PaperclipSourceRef): string {
|
|
const issue = ref.issueIdentifier ? issueReference(ref.issueIdentifier) : (ref.title ?? "Paperclip source");
|
|
if (ref.kind === "document") return `${issue} document:${ref.documentKey ?? "unknown"}`;
|
|
if (ref.kind === "comment") return `${issue} comment`;
|
|
return issue;
|
|
}
|
|
|
|
function sourceRefMarkdown(ref: PaperclipSourceRef): string {
|
|
const metadata = [
|
|
ref.redactionStatus ? `redaction=${ref.redactionStatus}` : null,
|
|
ref.redactionReasons?.length ? `redaction_reasons=${ref.redactionReasons.join("|")}` : null,
|
|
].filter((part): part is string => Boolean(part)).join(", ");
|
|
return `- ${sourceRefLabel(ref)}${metadata ? ` (${metadata})` : ""}`;
|
|
}
|
|
|
|
function issueSourceRef(issue: Issue): PaperclipSourceRef {
|
|
return {
|
|
kind: "issue",
|
|
issueId: issue.id,
|
|
issueIdentifier: issue.identifier ?? null,
|
|
projectId: issue.projectId ?? null,
|
|
title: issue.title,
|
|
updatedAt: isoString(issue.updatedAt) ?? undefined,
|
|
};
|
|
}
|
|
|
|
function projectPageSlug(input: { project: Project | null; rootIssue: Issue | null }): string {
|
|
return slugify(input.project?.name ?? input.rootIssue?.title ?? "paperclip-project");
|
|
}
|
|
|
|
function issueDescription(issue: Issue): string {
|
|
return issue.description?.trim() ?? "";
|
|
}
|
|
|
|
function issueReference(identifier: string): string {
|
|
const prefix = identifier.match(/^([A-Z]+)-\d+$/)?.[1];
|
|
return prefix ? `[${identifier}](/${prefix}/issues/${identifier})` : identifier;
|
|
}
|
|
|
|
function issueReferenceFor(issue: Issue): string {
|
|
return issue.identifier ? issueReference(issue.identifier) : "source issue";
|
|
}
|
|
|
|
function issueConcept(issue: Issue): string {
|
|
const title = issue.title
|
|
.replace(/^\s*(implement|add|update|fix|ship|write|create|publish|review|validate|investigate|design|refactor|support|make)\s+/i, "")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
const words = title.split(" ").filter(Boolean).slice(0, 5).join(" ");
|
|
return words || issue.title;
|
|
}
|
|
|
|
function issueNarrative(issue: Issue, maxLength = 260): string {
|
|
const details = issueDescription(issue);
|
|
return excerpt(details || issue.title, maxLength);
|
|
}
|
|
|
|
function conceptBullet(issue: Issue): string {
|
|
return `- **${issueConcept(issue)}.** ${issueNarrative(issue)} (${issueReferenceFor(issue)})`;
|
|
}
|
|
|
|
function excerpt(value: string, maxLength = 240): string {
|
|
const normalized = value.replace(/\s+/g, " ").trim();
|
|
if (normalized.length <= maxLength) return normalized;
|
|
return `${normalized.slice(0, maxLength - 1).trimEnd()}...`;
|
|
}
|
|
|
|
function hasDecisionSignal(value: string): boolean {
|
|
return /\b(accepted|approved|rejected|reversed|decided|decision|plan|proposal|approach|architecture|tradeoff)\b/i.test(value);
|
|
}
|
|
|
|
function hasRiskSignal(value: string): boolean {
|
|
return /\b(blocked|blocker|risk|warning|stale|conflict|failed|failure|regression)\b/i.test(value);
|
|
}
|
|
|
|
function hasDurableSignal(bundle: PaperclipSourceBundle, issues: Issue[]): boolean {
|
|
if (bundle.sourceRefs.some((ref) => ref.kind === "document" || ref.kind === "comment")) return true;
|
|
if (issues.some((issue) => issue.status !== "todo" || issueDescription(issue).length > 0)) return true;
|
|
return /\b(decision|approved|implemented|completed|blocked|risk|artifact|plan|handoff|merged|fixed)\b/i.test(bundle.markdown);
|
|
}
|
|
|
|
function standupPageContents(input: {
|
|
project: Project | null;
|
|
rootIssue: Issue | null;
|
|
issues: Issue[];
|
|
bundle: PaperclipSourceBundle;
|
|
pagePath: string;
|
|
durablePagePath: string;
|
|
}): string {
|
|
const currentAsOf = input.bundle.sourceWindowEnd ?? new Date().toISOString();
|
|
const title = input.project?.name ?? input.rootIssue?.title ?? "Paperclip Project";
|
|
const activeIssues = input.issues.filter((issue) => !["done", "cancelled"].includes(issue.status));
|
|
const recentlyChanged = [...input.issues]
|
|
.sort((a, b) => (isoString(b.updatedAt) ?? "").localeCompare(isoString(a.updatedAt) ?? ""))
|
|
.slice(0, 6);
|
|
const completedIssues = recentlyChanged.filter((issue) => issue.status === "done");
|
|
const advancedIssues = recentlyChanged.filter((issue) => issue.status !== "done" && issue.status !== "cancelled");
|
|
const decisionIssues = input.issues.filter((issue) => hasDecisionSignal(`${issue.title}\n${issueDescription(issue)}`)).slice(0, 6);
|
|
const riskIssues = input.issues.filter((issue) => issue.status === "blocked" || hasRiskSignal(`${issue.title}\n${issueDescription(issue)}`)).slice(0, 6);
|
|
const nextActionIssues = activeIssues.slice(0, 6);
|
|
const lead = activeIssues[0] ?? recentlyChanged[0] ?? null;
|
|
|
|
return [
|
|
"---",
|
|
`title: ${JSON.stringify(`${title} Standup`)}`,
|
|
"type: project-standup",
|
|
`project: ${JSON.stringify(projectPageSlug(input))}`,
|
|
`current_as_of: ${JSON.stringify(currentAsOf)}`,
|
|
"sources: []",
|
|
"---",
|
|
"",
|
|
`# ${title} Standup`,
|
|
"",
|
|
"## Executive Readout",
|
|
"",
|
|
lead
|
|
? `The current center of gravity is **${issueConcept(lead)}** (${issueReferenceFor(lead)}). ${input.bundle.clipped ? "The source window was clipped, so treat this as a bounded readout rather than the full live state." : "This is a high-level readout of the meaningful Paperclip work in the current source window."}`
|
|
: "No meaningful project movement was present in this source window.",
|
|
"",
|
|
"## What Changed",
|
|
"",
|
|
...(completedIssues.length
|
|
? completedIssues.map(conceptBullet)
|
|
: advancedIssues.length
|
|
? advancedIssues.map(conceptBullet)
|
|
: ["- No material completed or advanced work was identified in this source window."]),
|
|
"",
|
|
"## Decisions",
|
|
"",
|
|
...(decisionIssues.length
|
|
? decisionIssues.map(conceptBullet)
|
|
: ["- No decision changed the project direction in this source window."]),
|
|
"",
|
|
"## Blockers / Risks",
|
|
"",
|
|
...(riskIssues.length
|
|
? riskIssues.map(conceptBullet)
|
|
: ["- No active blocker or material risk surfaced in this source window."]),
|
|
"",
|
|
"## Next Actions",
|
|
"",
|
|
...(nextActionIssues.length
|
|
? nextActionIssues.map((issue) => `- **${issueConcept(issue)}.** Continue the work represented by ${issueReferenceFor(issue)}; focus on the next concrete deliverable rather than routine status churn.`)
|
|
: ["- No next action inferred from this source window."]),
|
|
"",
|
|
"## Links",
|
|
"",
|
|
`- Durable project overview: [[${input.durablePagePath}]]`,
|
|
...input.bundle.sourceRefs.slice(0, 12).map(sourceRefMarkdown),
|
|
"",
|
|
].filter((line): line is string => line !== null).join("\n");
|
|
}
|
|
|
|
function projectPageContents(input: {
|
|
project: Project | null;
|
|
rootIssue: Issue | null;
|
|
issues: Issue[];
|
|
bundle: PaperclipSourceBundle;
|
|
pagePath: string;
|
|
}): string {
|
|
const currentAsOf = input.bundle.sourceWindowEnd ?? new Date().toISOString();
|
|
const title = input.project?.name ?? input.rootIssue?.title ?? "Paperclip Project";
|
|
const description = input.project?.description?.trim() || input.rootIssue?.description?.trim() || "";
|
|
const activeIssues = input.issues.filter((issue) => !["done", "cancelled"].includes(issue.status));
|
|
const recentIssues = [...input.issues]
|
|
.sort((a, b) => (isoString(b.updatedAt) ?? "").localeCompare(isoString(a.updatedAt) ?? ""))
|
|
.slice(0, 8);
|
|
const decisionIssues = input.issues.filter((issue) => hasDecisionSignal(`${issue.title}\n${issueDescription(issue)}`)).slice(0, 8);
|
|
const riskIssues = input.issues.filter((issue) => issue.status === "blocked" || hasRiskSignal(`${issue.title}\n${issueDescription(issue)}`)).slice(0, 8);
|
|
|
|
return [
|
|
"---",
|
|
`title: ${JSON.stringify(title)}`,
|
|
"type: project",
|
|
`current_as_of: ${JSON.stringify(currentAsOf)}`,
|
|
"sources: []",
|
|
"---",
|
|
"",
|
|
`# ${title}`,
|
|
"",
|
|
"## Overview",
|
|
"",
|
|
description ? excerpt(description, 700) : `This page synthesizes Paperclip issue history into a stable project brief for ${title}.`,
|
|
"",
|
|
"## Current Direction",
|
|
"",
|
|
activeIssues.length
|
|
? `Work is currently organized around ${activeIssues.slice(0, 3).map((issue) => `**${issueConcept(issue)}** (${issueReferenceFor(issue)})`).join(", ")}. The useful project view is the concept being advanced, not the raw issue queue.`
|
|
: "The current source window does not show active project work.",
|
|
input.bundle.clipped ? "\nThe source window was clipped, so verify Paperclip before treating this as complete state." : null,
|
|
"",
|
|
"## Workstreams",
|
|
"",
|
|
...(recentIssues.length
|
|
? recentIssues.map(conceptBullet)
|
|
: ["- No meaningful workstream signal was identified in this source window."]),
|
|
"",
|
|
"## Decisions",
|
|
"",
|
|
...(decisionIssues.length
|
|
? decisionIssues.map(conceptBullet)
|
|
: ["- No durable decision signal was identified in this source window."]),
|
|
"",
|
|
"## Open Risks / Blockers",
|
|
"",
|
|
...(riskIssues.length
|
|
? riskIssues.map(conceptBullet)
|
|
: ["- No open risks or blockers identified in this source window."]),
|
|
"",
|
|
"## References",
|
|
"",
|
|
`- Current standup: [[${input.pagePath.replace(/\/index\.md$/, "/standup.md")}]]`,
|
|
...input.bundle.sourceRefs.slice(0, 12).map(sourceRefMarkdown),
|
|
"",
|
|
].filter((line): line is string => line !== null).join("\n");
|
|
}
|
|
|
|
function decisionsPageContents(input: { project: Project | null; rootIssue: Issue | null; issues: Issue[]; bundle: PaperclipSourceBundle }): string {
|
|
const title = input.project?.name ?? input.rootIssue?.title ?? "Paperclip Project";
|
|
const decisionIssues = input.issues.filter((issue) => hasDecisionSignal(`${issue.title}\n${issueDescription(issue)}`));
|
|
return [
|
|
`# ${title} Decisions`,
|
|
"",
|
|
"Durable project decisions grouped by concept. Use this as an editorial memory of why the project changed direction, not as an issue log.",
|
|
"",
|
|
...(decisionIssues.length
|
|
? decisionIssues.map((issue) => [
|
|
`## ${issueConcept(issue)}`,
|
|
"",
|
|
issueDescription(issue) ? excerpt(issueDescription(issue), 900) : "_No decision details beyond the issue title._",
|
|
"",
|
|
`Source: ${issueReferenceFor(issue)}`,
|
|
"",
|
|
].join("\n"))
|
|
: ["No durable decisions identified in this source window.", ""]),
|
|
"## References",
|
|
"",
|
|
...input.bundle.sourceRefs.slice(0, 40).map(sourceRefMarkdown),
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
function historyPageContents(input: { project: Project | null; rootIssue: Issue | null; issues: Issue[]; bundle: PaperclipSourceBundle }): string {
|
|
const title = input.project?.name ?? input.rootIssue?.title ?? "Paperclip Project";
|
|
const timeline = [...input.issues]
|
|
.sort((a, b) => (isoString(a.updatedAt) ?? "").localeCompare(isoString(b.updatedAt) ?? ""))
|
|
.slice(-30);
|
|
return [
|
|
`# ${title} History`,
|
|
"",
|
|
"Narrative history of meaningful project movement. Group by what changed in the work, not by dates or metadata.",
|
|
"",
|
|
"## Meaningful Project Movement",
|
|
"",
|
|
...(timeline.length
|
|
? timeline.map(conceptBullet)
|
|
: ["- No source issues in this window."]),
|
|
"",
|
|
"## References",
|
|
"",
|
|
...input.bundle.sourceRefs.slice(0, 40).map(sourceRefMarkdown),
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
function updateProjectIndexContents(current: string | null, input: { pagePath: string; standupPath: string; title: string }): string {
|
|
const base = current?.trimEnd() || "# Index\n\n## Sources\n\n_(none yet)_\n\n## Projects\n\n_(none yet)_\n\n## Entities\n\n_(none yet)_\n\n## Concepts\n\n_(none yet)_\n\n## Synthesis\n\n_(none yet)_";
|
|
const entry = `- [[${input.pagePath}]] — ${input.title} project overview. Current executive standup: [[${input.standupPath}]].`;
|
|
const projectsMatch = base.match(/(^## Projects\n)([\s\S]*?)(?=^## |\s*$)/m);
|
|
if (!projectsMatch || projectsMatch.index == null) {
|
|
return `${base}\n\n## Projects\n\n${entry}\n`;
|
|
}
|
|
const start = projectsMatch.index + projectsMatch[1].length;
|
|
const end = start + projectsMatch[2].length;
|
|
const existingLines = projectsMatch[2]
|
|
.split("\n")
|
|
.map((line) => line.trimEnd())
|
|
.filter((line) => line.trim() && line.trim() !== "_(none yet)_" && !line.includes(input.pagePath) && !line.includes(input.standupPath));
|
|
const nextLines = [...existingLines, entry].sort((a, b) => a.localeCompare(b));
|
|
return `${base.slice(0, start)}${nextLines.join("\n")}\n\n${base.slice(end).replace(/^\n+/, "")}`.trimEnd() + "\n";
|
|
}
|
|
|
|
function appendProjectLogContents(current: string | null, input: { standupPath: string; pagePath: string; runId: string; sourceHash: string; status: string; warnings: string[] }): string {
|
|
const base = current?.trimEnd() || "# Log\n\nAppend-only chronological record of wiki operations.";
|
|
const warningLines = input.warnings.length
|
|
? input.warnings.map((warning) => `- warning: ${warning}`)
|
|
: ["- warnings: none"];
|
|
const entry = [
|
|
`## [${new Date().toISOString().slice(0, 10)}] paperclip-distill | ${input.status}`,
|
|
`- standup: \`${input.standupPath}\``,
|
|
`- page: \`${input.pagePath}\``,
|
|
`- run: \`${input.runId}\``,
|
|
`- source hash: \`${input.sourceHash}\``,
|
|
...warningLines,
|
|
].join("\n");
|
|
return `${base}\n\n${entry}\n`;
|
|
}
|
|
|
|
function patchForPage(input: {
|
|
path: string;
|
|
operationType: PaperclipDistillationPatchOperation;
|
|
currentHash: string | null;
|
|
contents: string;
|
|
bundle: PaperclipSourceBundle;
|
|
confidence: "high" | "medium" | "low";
|
|
warnings: string[];
|
|
humanReviewRequired: boolean;
|
|
}): PaperclipDistillationPatch {
|
|
return {
|
|
pagePath: input.path,
|
|
operationType: input.operationType,
|
|
currentHash: input.currentHash,
|
|
proposedHash: contentHash(input.contents),
|
|
proposedContents: input.contents,
|
|
sourceHash: input.bundle.sourceHash,
|
|
sourceRefs: input.bundle.sourceRefs,
|
|
cursorWindow: {
|
|
start: input.bundle.sourceWindowStart,
|
|
end: input.bundle.sourceWindowEnd,
|
|
},
|
|
confidence: input.confidence,
|
|
warnings: input.warnings,
|
|
humanReviewRequired: input.humanReviewRequired,
|
|
};
|
|
}
|
|
|
|
async function readPageBinding(ctx: PluginContext, input: { companyId: string; wikiId: string; spaceId: string; pagePath: string }) {
|
|
const rows = await ctx.db.query<{ last_applied_source_hash: string | null }>(
|
|
`SELECT last_applied_source_hash
|
|
FROM ${pageBindingTable(ctx)}
|
|
WHERE company_id = $1
|
|
AND wiki_id = $2
|
|
AND space_id = $3
|
|
AND page_path = $4
|
|
LIMIT 1`,
|
|
[input.companyId, input.wikiId, input.spaceId, input.pagePath],
|
|
);
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
async function upsertPageBinding(ctx: PluginContext, input: {
|
|
companyId: string;
|
|
wikiId: string;
|
|
spaceId: string;
|
|
spaceSlug: string;
|
|
projectId: string | null;
|
|
rootIssueId: string | null;
|
|
pagePath: string;
|
|
sourceHash: string;
|
|
runId: string;
|
|
metadata?: Record<string, unknown>;
|
|
}) {
|
|
await ctx.db.execute(
|
|
`INSERT INTO ${pageBindingTable(ctx)} AS paperclip_page_bindings
|
|
(id, company_id, wiki_id, space_id, project_id, root_issue_id, page_path, last_applied_source_hash, last_distillation_run_id, metadata)
|
|
VALUES ($1, $2, $3, $10, $4, $5, $6, $7, $8, $9::jsonb)
|
|
ON CONFLICT (company_id, wiki_id, space_id, page_path)
|
|
DO UPDATE SET last_applied_source_hash = EXCLUDED.last_applied_source_hash,
|
|
last_distillation_run_id = EXCLUDED.last_distillation_run_id,
|
|
metadata = paperclip_page_bindings.metadata || EXCLUDED.metadata,
|
|
updated_at = now()`,
|
|
[
|
|
randomUUID(),
|
|
input.companyId,
|
|
input.wikiId,
|
|
input.projectId,
|
|
input.rootIssueId,
|
|
input.pagePath,
|
|
input.sourceHash,
|
|
input.runId,
|
|
jsonParam({ spaceSlug: input.spaceSlug, ...(input.metadata ?? {}) }),
|
|
input.spaceId,
|
|
],
|
|
);
|
|
}
|
|
|
|
async function autoApplyEnabled(ctx: PluginContext, requested: boolean | undefined): Promise<boolean> {
|
|
if (getDistillationAutoApplyRestriction().autoApplyRestriction) {
|
|
return false;
|
|
}
|
|
const config = await ctx.config.get();
|
|
const configured = (config as { autoApplyIngestPatches?: unknown }).autoApplyIngestPatches !== false;
|
|
return configured && requested !== false;
|
|
}
|
|
|
|
export function getDistillationAutoApplyRestriction(): DistillationAutoApplyRestriction {
|
|
const rawMode = process.env.PAPERCLIP_DEPLOYMENT_MODE;
|
|
const rawExposure = process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE;
|
|
const deploymentMode =
|
|
rawMode === "local_trusted" || rawMode === "authenticated" ? rawMode : null;
|
|
const deploymentExposure =
|
|
rawExposure === "private" || rawExposure === "public" ? rawExposure : null;
|
|
const blocked = deploymentMode === "authenticated" && deploymentExposure === "public";
|
|
return {
|
|
autoApplyAllowed: !blocked,
|
|
autoApplyRestriction: blocked ? PUBLIC_DISTILLATION_AUTO_APPLY_RESTRICTION : null,
|
|
deploymentMode,
|
|
deploymentExposure,
|
|
};
|
|
}
|
|
|
|
export async function distillPaperclipProjectPage(ctx: PluginContext, input: PaperclipProjectPageDistillationInput) {
|
|
if (!input.projectId && !input.rootIssueId) {
|
|
throw new Error("projectId or rootIssueId is required");
|
|
}
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
assertPaperclipSourceScopePayload(input);
|
|
const space = await requirePaperclipIngestionPolicy(ctx, { companyId: input.companyId, wikiId, spaceSlug: input.spaceSlug }, "execute", { requireEnabledProfile: true });
|
|
const scope = paperclipCursorScopeMetadata(input);
|
|
const issues = await listPaperclipBundleIssues(ctx, input);
|
|
const project = scope.projectId ? await ctx.projects.get(scope.projectId, input.companyId) : null;
|
|
const rootIssue = scope.rootIssueId ? await ctx.issues.get(scope.rootIssueId, input.companyId) : null;
|
|
const slug = projectPageSlug({ project, rootIssue });
|
|
const projectDir = `wiki/projects/${slug}`;
|
|
const standupPath = assertPagePath(`${projectDir}/standup.md`);
|
|
const pagePath = assertPagePath(`${projectDir}/index.md`);
|
|
const run = await createPaperclipDistillationRun(ctx, input);
|
|
const bundle = run.bundle;
|
|
const current = await readCurrentWithHash(ctx, input.companyId, pagePath, space);
|
|
assertExpectedHash(input.expectedProjectPageHash, current.hash, pagePath);
|
|
|
|
if (!hasDurableSignal(bundle, issues)) {
|
|
await recordPaperclipDistillationOutcome(ctx, {
|
|
companyId: input.companyId,
|
|
wikiId,
|
|
spaceSlug: space.slug,
|
|
runId: run.runId,
|
|
cursorId: run.cursorId,
|
|
status: "succeeded",
|
|
sourceHash: bundle.sourceHash,
|
|
sourceWindowEnd: bundle.sourceWindowEnd,
|
|
warning: "Skipped low-signal Paperclip source window.",
|
|
});
|
|
return {
|
|
status: "skipped",
|
|
reason: "low_signal",
|
|
wikiId,
|
|
runId: run.runId,
|
|
cursorId: run.cursorId,
|
|
sourceHash: bundle.sourceHash,
|
|
warnings: ["Skipped low-signal Paperclip source window."],
|
|
patches: [] as PaperclipDistillationPatch[],
|
|
};
|
|
}
|
|
|
|
const existingBinding = await readPageBinding(ctx, { companyId: input.companyId, wikiId, spaceId: space.id, pagePath });
|
|
if (existingBinding?.last_applied_source_hash === bundle.sourceHash) {
|
|
await recordPaperclipDistillationOutcome(ctx, {
|
|
companyId: input.companyId,
|
|
wikiId,
|
|
spaceSlug: space.slug,
|
|
runId: run.runId,
|
|
cursorId: run.cursorId,
|
|
status: "succeeded",
|
|
sourceHash: bundle.sourceHash,
|
|
sourceWindowEnd: bundle.sourceWindowEnd,
|
|
warning: "Skipped unchanged Paperclip source hash.",
|
|
});
|
|
return {
|
|
status: "skipped",
|
|
reason: "unchanged_source",
|
|
wikiId,
|
|
runId: run.runId,
|
|
cursorId: run.cursorId,
|
|
sourceHash: bundle.sourceHash,
|
|
warnings: ["Skipped unchanged Paperclip source hash."],
|
|
patches: [] as PaperclipDistillationPatch[],
|
|
};
|
|
}
|
|
|
|
const warnings = [...bundle.warnings];
|
|
const confidence: "high" | "medium" | "low" = bundle.clipped ? "medium" : "high";
|
|
const reviewRequired = bundle.clipped || warnings.length > 0;
|
|
const title = project?.name ?? rootIssue?.title ?? "Paperclip Project";
|
|
const standupCurrent = await readCurrentWithHash(ctx, input.companyId, standupPath, space);
|
|
const standupContents = standupPageContents({ project, rootIssue, issues, bundle, pagePath: standupPath, durablePagePath: pagePath });
|
|
const projectContents = projectPageContents({ project, rootIssue, issues, bundle, pagePath });
|
|
const indexCurrent = await readCurrentWithHash(ctx, input.companyId, "wiki/index.md", space);
|
|
const logCurrent = await readCurrentWithHash(ctx, input.companyId, "wiki/log.md", space);
|
|
const indexContents = updateProjectIndexContents(indexCurrent.contents, {
|
|
pagePath,
|
|
standupPath,
|
|
title,
|
|
});
|
|
const logContents = appendProjectLogContents(logCurrent.contents, {
|
|
standupPath,
|
|
pagePath,
|
|
runId: run.runId,
|
|
sourceHash: bundle.sourceHash,
|
|
status: "proposed",
|
|
warnings,
|
|
});
|
|
const patches: PaperclipDistillationPatch[] = [
|
|
patchForPage({ path: standupPath, operationType: "standup_update", currentHash: standupCurrent.hash, contents: standupContents, bundle, confidence, warnings, humanReviewRequired: reviewRequired }),
|
|
patchForPage({ path: pagePath, operationType: "project_page_distill", currentHash: current.hash, contents: projectContents, bundle, confidence, warnings, humanReviewRequired: reviewRequired }),
|
|
patchForPage({ path: "wiki/index.md", operationType: "index_refresh", currentHash: indexCurrent.hash, contents: indexContents, bundle, confidence: "high", warnings: [], humanReviewRequired: false }),
|
|
patchForPage({ path: "wiki/log.md", operationType: "log_append", currentHash: logCurrent.hash, contents: logContents, bundle, confidence: "high", warnings: [], humanReviewRequired: false }),
|
|
];
|
|
|
|
if (input.includeSupportingPages !== false) {
|
|
const hasDecisions = issues.some((issue) => hasDecisionSignal(`${issue.title}\n${issueDescription(issue)}`));
|
|
if (hasDecisions) {
|
|
const decisionsPath = assertPagePath(`${projectDir}/decisions.md`);
|
|
const decisionsCurrent = await readCurrentWithHash(ctx, input.companyId, decisionsPath, space);
|
|
patches.push(patchForPage({
|
|
path: decisionsPath,
|
|
operationType: "decision_distill",
|
|
currentHash: decisionsCurrent.hash,
|
|
contents: decisionsPageContents({ project, rootIssue, issues, bundle }),
|
|
bundle,
|
|
confidence,
|
|
warnings,
|
|
humanReviewRequired: reviewRequired,
|
|
}));
|
|
}
|
|
const historyPath = assertPagePath(`${projectDir}/history.md`);
|
|
const historyCurrent = await readCurrentWithHash(ctx, input.companyId, historyPath, space);
|
|
patches.push(patchForPage({
|
|
path: historyPath,
|
|
operationType: "history_distill",
|
|
currentHash: historyCurrent.hash,
|
|
contents: historyPageContents({ project, rootIssue, issues, bundle }),
|
|
bundle,
|
|
confidence,
|
|
warnings,
|
|
humanReviewRequired: reviewRequired,
|
|
}));
|
|
}
|
|
|
|
const autoApplyRestriction = getDistillationAutoApplyRestriction();
|
|
const canAutoApply = await autoApplyEnabled(ctx, input.autoApply);
|
|
if (!canAutoApply || reviewRequired) {
|
|
const autoApplyWarning =
|
|
autoApplyRestriction.autoApplyRestriction
|
|
?? "Auto-apply policy disabled; proposed patches require review.";
|
|
await recordPaperclipDistillationOutcome(ctx, {
|
|
companyId: input.companyId,
|
|
wikiId,
|
|
spaceSlug: space.slug,
|
|
runId: run.runId,
|
|
cursorId: run.cursorId,
|
|
status: "review_required",
|
|
sourceHash: bundle.sourceHash,
|
|
sourceWindowEnd: bundle.sourceWindowEnd,
|
|
warning: canAutoApply ? "Human review required by patch warnings." : autoApplyWarning,
|
|
});
|
|
return {
|
|
status: "review_required",
|
|
wikiId,
|
|
runId: run.runId,
|
|
cursorId: run.cursorId,
|
|
sourceHash: bundle.sourceHash,
|
|
patches,
|
|
appliedPages: [] as string[],
|
|
warnings: canAutoApply ? warnings : [autoApplyWarning, ...warnings],
|
|
};
|
|
}
|
|
|
|
const appliedPages: string[] = [];
|
|
for (const patch of patches) {
|
|
await writeWikiPage(ctx, {
|
|
companyId: input.companyId,
|
|
wikiId,
|
|
spaceSlug: space.slug,
|
|
path: patch.pagePath,
|
|
contents: patch.proposedContents,
|
|
expectedHash: patch.currentHash,
|
|
summary: `Paperclip distillation ${patch.operationType} from ${bundle.sourceHash}`,
|
|
sourceRefs: patch.sourceRefs,
|
|
});
|
|
await upsertPageBinding(ctx, {
|
|
companyId: input.companyId,
|
|
wikiId,
|
|
spaceId: space.id,
|
|
spaceSlug: space.slug,
|
|
projectId: scope.projectId,
|
|
rootIssueId: scope.rootIssueId,
|
|
pagePath: patch.pagePath,
|
|
sourceHash: bundle.sourceHash,
|
|
runId: run.runId,
|
|
metadata: { operationType: patch.operationType },
|
|
});
|
|
appliedPages.push(patch.pagePath);
|
|
}
|
|
await recordPaperclipDistillationOutcome(ctx, {
|
|
companyId: input.companyId,
|
|
wikiId,
|
|
spaceSlug: space.slug,
|
|
runId: run.runId,
|
|
cursorId: run.cursorId,
|
|
status: "succeeded",
|
|
sourceHash: bundle.sourceHash,
|
|
sourceWindowEnd: bundle.sourceWindowEnd,
|
|
});
|
|
|
|
return {
|
|
status: "applied",
|
|
wikiId,
|
|
runId: run.runId,
|
|
cursorId: run.cursorId,
|
|
sourceHash: bundle.sourceHash,
|
|
patches,
|
|
appliedPages,
|
|
warnings,
|
|
};
|
|
}
|
|
|
|
function truncateEventSource(contents: string, maxCharacters: number): string {
|
|
if (contents.length <= maxCharacters) return contents;
|
|
return `${contents.slice(0, maxCharacters)}\n\n[Truncated by LLM Wiki event ingestion policy at ${maxCharacters} characters.]\n`;
|
|
}
|
|
|
|
function eventPayload(event: PluginEvent): Record<string, unknown> {
|
|
return event.payload && typeof event.payload === "object" && !Array.isArray(event.payload)
|
|
? event.payload as Record<string, unknown>
|
|
: {};
|
|
}
|
|
|
|
function sourceTitleForIssue(issue: Issue): string {
|
|
return issue.identifier ? `${issue.identifier} ${issue.title}` : issue.title;
|
|
}
|
|
|
|
function rawPathForPaperclipEvent(input: {
|
|
sourceKind: WikiEventIngestionSource;
|
|
issue: Issue;
|
|
label: string;
|
|
contents: string;
|
|
event: PluginEvent;
|
|
}): string {
|
|
const identifier = input.issue.identifier ?? input.issue.id.slice(0, 8);
|
|
const eventDate = input.event.occurredAt.slice(0, 10);
|
|
return assertRawPath(`raw/paperclip/${input.sourceKind}/${eventDate}-${slugify(identifier)}-${slugify(input.label)}-${contentHash(input.contents).slice(0, 8)}.md`);
|
|
}
|
|
|
|
function formatIssueEventSource(issue: Issue, event: PluginEvent, maxCharacters: number): string {
|
|
return truncateEventSource([
|
|
`# Paperclip issue: ${sourceTitleForIssue(issue)}`,
|
|
"",
|
|
"## Provenance",
|
|
"",
|
|
`- Company ID: ${issue.companyId}`,
|
|
`- Issue ID: ${issue.id}`,
|
|
issue.identifier ? `- Issue identifier: ${issue.identifier}` : null,
|
|
`- Event type: ${event.eventType}`,
|
|
`- Event ID: ${event.eventId}`,
|
|
`- Event occurred at: ${event.occurredAt}`,
|
|
`- Status: ${issue.status}`,
|
|
`- Priority: ${issue.priority}`,
|
|
"",
|
|
"## Issue",
|
|
"",
|
|
issue.description?.trim() ? issue.description.trim() : "_No issue description._",
|
|
].filter((line): line is string => line !== null).join("\n"), maxCharacters);
|
|
}
|
|
|
|
function formatCommentEventSource(issue: Issue, comment: IssueComment, event: PluginEvent, maxCharacters: number): string {
|
|
return truncateEventSource([
|
|
`# Paperclip comment on ${sourceTitleForIssue(issue)}`,
|
|
"",
|
|
"## Provenance",
|
|
"",
|
|
`- Company ID: ${issue.companyId}`,
|
|
`- Issue ID: ${issue.id}`,
|
|
issue.identifier ? `- Issue identifier: ${issue.identifier}` : null,
|
|
`- Comment ID: ${comment.id}`,
|
|
`- Event type: ${event.eventType}`,
|
|
`- Event ID: ${event.eventId}`,
|
|
`- Event occurred at: ${event.occurredAt}`,
|
|
"",
|
|
"## Comment",
|
|
"",
|
|
comment.body,
|
|
].filter((line): line is string => line !== null).join("\n"), maxCharacters);
|
|
}
|
|
|
|
function formatDocumentEventSource(issue: Issue, document: IssueDocument, event: PluginEvent, maxCharacters: number): string {
|
|
return truncateEventSource([
|
|
`# Paperclip document: ${document.title ?? document.key}`,
|
|
"",
|
|
"## Provenance",
|
|
"",
|
|
`- Company ID: ${issue.companyId}`,
|
|
`- Issue ID: ${issue.id}`,
|
|
issue.identifier ? `- Issue identifier: ${issue.identifier}` : null,
|
|
`- Document ID: ${document.id}`,
|
|
`- Document key: ${document.key}`,
|
|
`- Event type: ${event.eventType}`,
|
|
`- Event ID: ${event.eventId}`,
|
|
`- Event occurred at: ${event.occurredAt}`,
|
|
`- Format: ${document.format}`,
|
|
`- Revision: ${document.latestRevisionNumber}`,
|
|
"",
|
|
"## Document",
|
|
"",
|
|
document.body,
|
|
].filter((line): line is string => line !== null).join("\n"), maxCharacters);
|
|
}
|
|
|
|
async function recordPaperclipCursorObservation(ctx: PluginContext, input: {
|
|
companyId: string;
|
|
wikiId: string;
|
|
space: WikiSpace;
|
|
sourceKind: WikiEventIngestionSource;
|
|
sourceId: string;
|
|
issue: Issue;
|
|
event: PluginEvent;
|
|
}): Promise<Extract<PaperclipEventIngestResult, { status: "recorded" }>> {
|
|
const cursorId = await upsertPaperclipDistillationCursor(ctx, {
|
|
companyId: input.companyId,
|
|
wikiId: input.wikiId,
|
|
spaceId: input.space.id,
|
|
projectId: input.issue.projectId ?? null,
|
|
rootIssueId: null,
|
|
observedAt: input.event.occurredAt,
|
|
metadata: {
|
|
lastEventId: input.event.eventId,
|
|
lastEventType: input.event.eventType,
|
|
lastSourceKind: input.sourceKind,
|
|
lastSourceId: input.sourceId,
|
|
lastIssueId: input.issue.id,
|
|
lastIssueIdentifier: input.issue.identifier ?? null,
|
|
},
|
|
});
|
|
await ctx.state.set(eventIngestionDedupKey(input.companyId, input.wikiId, input.space.id, input.sourceKind, input.sourceId), {
|
|
eventId: input.event.eventId,
|
|
cursorId,
|
|
issueId: input.issue.id,
|
|
spaceSlug: input.space.slug,
|
|
observedAt: new Date().toISOString(),
|
|
});
|
|
return {
|
|
status: "recorded",
|
|
sourceKind: input.sourceKind,
|
|
sourceId: input.sourceId,
|
|
cursorId,
|
|
issueId: input.issue.id,
|
|
};
|
|
}
|
|
|
|
async function paperclipProfileIncludesIssue(ctx: PluginContext, input: {
|
|
companyId: string;
|
|
issue: Issue;
|
|
profile: PaperclipIngestionProfileV1;
|
|
}): Promise<boolean> {
|
|
for (const scope of input.profile.sourceScopes) {
|
|
if (scope.kind === "company_all") return true;
|
|
if (scope.kind === "selected_projects" && input.issue.projectId && scope.projectIds.includes(input.issue.projectId)) {
|
|
return true;
|
|
}
|
|
if (scope.kind === "active_projects" && input.issue.projectId) {
|
|
const project = await ctx.projects.get(input.issue.projectId, input.companyId);
|
|
const statuses = scope.statuses ?? ["in_progress"];
|
|
if (project && statuses.includes(project.status as "in_progress" | "todo" | "done") && isActiveDistillationProject(project)) {
|
|
return true;
|
|
}
|
|
}
|
|
if (scope.kind === "root_issues") {
|
|
for (const rootIssueId of scope.issueIds) {
|
|
if (input.issue.id === rootIssueId) return true;
|
|
const subtree = await ctx.issues.getSubtree(rootIssueId, input.companyId, { includeRoot: true });
|
|
if (subtree.issues.some((issue) => issue.id === input.issue.id)) return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function routePaperclipCursorObservation(ctx: PluginContext, input: {
|
|
companyId: string;
|
|
sourceKind: WikiEventIngestionSource;
|
|
sourceId: string;
|
|
issue: Issue;
|
|
event: PluginEvent;
|
|
}): Promise<PaperclipEventIngestResult> {
|
|
const { spaces } = await listSpaces(ctx, { companyId: input.companyId, wikiId: DEFAULT_WIKI_ID });
|
|
const recorded: Array<Extract<PaperclipEventIngestResult, { status: "recorded" }>> = [];
|
|
let eligibleProfileCount = 0;
|
|
for (const space of spaces) {
|
|
const profile = await profileForSpace(ctx, input.companyId, space);
|
|
if (!profile.enabled) continue;
|
|
const policy = evaluatePaperclipProfilePolicy({ space, profile, purpose: "event_routing", requireEnabledProfile: true });
|
|
if (!policy.allowed) continue;
|
|
if (!profile.sourceKinds[input.sourceKind]) continue;
|
|
if (!(await paperclipProfileIncludesIssue(ctx, { companyId: input.companyId, issue: input.issue, profile }))) continue;
|
|
eligibleProfileCount += 1;
|
|
if (eligibleProfileCount > MAX_PAPERCLIP_DISTILLATION_FAN_OUT) {
|
|
throw new Error(`Paperclip ingestion fan-out exceeds the hard cap of ${MAX_PAPERCLIP_DISTILLATION_FAN_OUT} enabled profiles.`);
|
|
}
|
|
if (await ctx.state.get(eventIngestionDedupKey(input.companyId, space.wikiId, space.id, input.sourceKind, input.sourceId))) {
|
|
continue;
|
|
}
|
|
recorded.push(await recordPaperclipCursorObservation(ctx, {
|
|
...input,
|
|
wikiId: space.wikiId,
|
|
space,
|
|
}));
|
|
}
|
|
return recorded[0] ?? { status: "skipped", reason: "source_disabled" };
|
|
}
|
|
|
|
export async function handlePaperclipEventIngestion(ctx: PluginContext, event: PluginEvent): Promise<PaperclipEventIngestResult> {
|
|
const companyId = event.companyId;
|
|
|
|
const issueId = stringField(event.entityId);
|
|
if (!issueId) return { status: "skipped", reason: "unsupported_event" };
|
|
const issue = await ctx.issues.get(issueId, companyId);
|
|
if (!issue) return { status: "skipped", reason: "missing_issue" };
|
|
if (isLlmWikiOperationIssue(issue)) return { status: "skipped", reason: "plugin_operation" };
|
|
|
|
const payload = eventPayload(event);
|
|
if (event.eventType === "issue.created" || event.eventType === "issue.updated") {
|
|
const sourceId = `${event.eventType}:${issue.id}:${event.eventId}`;
|
|
return routePaperclipCursorObservation(ctx, {
|
|
companyId,
|
|
sourceKind: "issues",
|
|
sourceId,
|
|
issue,
|
|
event,
|
|
});
|
|
}
|
|
|
|
if (event.eventType === "issue.comment.created") {
|
|
const commentId = stringField(payload.commentId);
|
|
if (!commentId) return { status: "skipped", reason: "missing_comment" };
|
|
const sourceId = `comment:${commentId}`;
|
|
return routePaperclipCursorObservation(ctx, {
|
|
companyId,
|
|
sourceKind: "comments",
|
|
sourceId,
|
|
issue,
|
|
event,
|
|
});
|
|
}
|
|
|
|
if (event.eventType === "issue.document.created" || event.eventType === "issue.document.updated") {
|
|
const documentKey = stringField(payload.key) ?? stringField(payload.documentKey);
|
|
if (!documentKey) return { status: "skipped", reason: "missing_document" };
|
|
const revision = stringField(payload.revisionId) ?? stringField(payload.latestRevisionId) ?? stringField(payload.revisionNumber) ?? event.eventId;
|
|
const sourceId = `document:${issue.id}:${documentKey}:revision:${revision}`;
|
|
return routePaperclipCursorObservation(ctx, {
|
|
companyId,
|
|
sourceKind: "documents",
|
|
sourceId,
|
|
issue,
|
|
event,
|
|
});
|
|
}
|
|
|
|
return { status: "skipped", reason: "unsupported_event" };
|
|
}
|
|
|
|
function queryStreamChannel(operationId: string): string {
|
|
return `llm-wiki:query:${operationId}`;
|
|
}
|
|
|
|
function buildQueryPrompt(input: { companyId: string; wikiId: string; space: WikiSpace; question: string }): string {
|
|
return [
|
|
QUERY_PROMPT,
|
|
`Company ID: ${input.companyId}`,
|
|
`Wiki ID: ${input.wikiId}`,
|
|
`Space: ${input.space.displayName} (${input.space.slug})`,
|
|
`Space root: ${operationSpaceRoot(input.space)}`,
|
|
`Tool arguments: always pass wikiId \`${input.wikiId}\` and spaceSlug \`${input.space.slug}\`.`,
|
|
"Use the LLM Wiki plugin tools against that space only. Read wiki/index.md first with wiki_read_page, then use wiki_search, wiki_read_page, wiki_list_sources, and wiki_read_source as needed.",
|
|
"Cite the wiki page paths and raw source paths you used. If the wiki does not contain enough evidence, say that directly.",
|
|
`Question: ${input.question}`,
|
|
].join("\n\n");
|
|
}
|
|
|
|
async function markOperation(ctx: PluginContext, input: {
|
|
companyId: string;
|
|
operationId: string;
|
|
status: string;
|
|
runId?: string | null;
|
|
warning?: string | null;
|
|
affectedPages?: unknown[] | null;
|
|
metadata?: Record<string, unknown> | null;
|
|
}) {
|
|
await ctx.db.execute(
|
|
`UPDATE ${tableName(ctx.db.namespace, "wiki_operations")}
|
|
SET status = $3,
|
|
run_ids = CASE WHEN $4::jsonb = '[]'::jsonb THEN run_ids ELSE run_ids || $4::jsonb END,
|
|
warnings = CASE WHEN $5::jsonb = '[]'::jsonb THEN warnings ELSE warnings || $5::jsonb END,
|
|
affected_pages = CASE WHEN $6::jsonb = '[]'::jsonb THEN affected_pages ELSE $6::jsonb END,
|
|
metadata = metadata || $7::jsonb,
|
|
updated_at = now()
|
|
WHERE company_id = $1 AND id = $2`,
|
|
[
|
|
input.companyId,
|
|
input.operationId,
|
|
input.status,
|
|
jsonArrayParam(input.runId ? [input.runId] : []),
|
|
jsonArrayParam(input.warning ? [input.warning] : []),
|
|
jsonArrayParam(input.affectedPages ?? []),
|
|
jsonParam(input.metadata ?? {}),
|
|
],
|
|
);
|
|
}
|
|
|
|
function isTerminalSessionEvent(event: AgentSessionEvent): boolean {
|
|
return event.eventType === "done" || event.eventType === "error";
|
|
}
|
|
|
|
export async function startWikiQuerySession(ctx: PluginContext, input: QuerySessionInput) {
|
|
const question = requireString(input.question, "question");
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
const space = await resolveSpace(ctx, { companyId: input.companyId, wikiId, spaceSlug: input.spaceSlug });
|
|
const operation = await createOperationIssue(ctx, {
|
|
companyId: input.companyId,
|
|
wikiId,
|
|
spaceSlug: space.slug,
|
|
operationType: "query",
|
|
title: input.title ?? `Query LLM Wiki: ${question.slice(0, 72)}`,
|
|
prompt: question,
|
|
});
|
|
const agentId = operation.issue.assigneeAgentId;
|
|
const channel = queryStreamChannel(operation.operationId);
|
|
|
|
if (!agentId) {
|
|
const warning = "No configured Wiki Maintainer agent is available for this company.";
|
|
await markOperation(ctx, {
|
|
companyId: input.companyId,
|
|
operationId: operation.operationId,
|
|
status: "blocked",
|
|
warning,
|
|
});
|
|
await ctx.issues.update(operation.issue.id, { status: "blocked" }, input.companyId);
|
|
await ctx.issues.createComment(operation.issue.id, warning, input.companyId);
|
|
throw new Error(warning);
|
|
}
|
|
|
|
const agent = await ctx.agents.get(agentId, input.companyId);
|
|
if (!agent || agent.status === "paused" || agent.status === "terminated" || agent.status === "pending_approval") {
|
|
const warning = agent
|
|
? `Wiki Maintainer agent is not invokable while status is ${agent.status}.`
|
|
: "Wiki Maintainer agent could not be loaded.";
|
|
await markOperation(ctx, {
|
|
companyId: input.companyId,
|
|
operationId: operation.operationId,
|
|
status: "blocked",
|
|
warning,
|
|
});
|
|
await ctx.issues.update(operation.issue.id, { status: "blocked" }, input.companyId);
|
|
await ctx.issues.createComment(operation.issue.id, warning, input.companyId);
|
|
throw new Error(warning);
|
|
}
|
|
|
|
const session = await ctx.agents.sessions.create(agentId, input.companyId, {
|
|
taskKey: `plugin:${PLUGIN_ID}:session:wiki:${wikiId}:query:${operation.operationId}`,
|
|
reason: "LLM Wiki query session",
|
|
});
|
|
await ctx.db.execute(
|
|
`INSERT INTO ${tableName(ctx.db.namespace, "wiki_query_sessions")}
|
|
(id, company_id, wiki_id, space_id, hidden_issue_id, agent_session_id, status, filed_outputs)
|
|
VALUES ($1, $2, $3, $6, $4, $5, 'active', '[]'::jsonb)`,
|
|
[operation.operationId, input.companyId, wikiId, operation.issue.id, session.sessionId, space.id],
|
|
);
|
|
|
|
const prompt = buildQueryPrompt({ companyId: input.companyId, wikiId, space, question });
|
|
ctx.streams.open(channel, input.companyId);
|
|
ctx.streams.emit(channel, {
|
|
type: "query.started",
|
|
operationId: operation.operationId,
|
|
querySessionId: operation.operationId,
|
|
issueId: operation.issue.id,
|
|
sessionId: session.sessionId,
|
|
question,
|
|
});
|
|
|
|
let answer = "";
|
|
const sendResult = await ctx.agents.sessions.sendMessage(session.sessionId, input.companyId, {
|
|
prompt,
|
|
reason: "LLM Wiki query",
|
|
onEvent: (event) => {
|
|
if (event.eventType === "chunk" && event.stream !== "stderr" && event.message) {
|
|
answer += event.message;
|
|
}
|
|
ctx.streams.emit(channel, {
|
|
type: "agent.event",
|
|
operationId: operation.operationId,
|
|
querySessionId: operation.operationId,
|
|
eventType: event.eventType,
|
|
stream: event.stream,
|
|
message: event.message,
|
|
payload: event.payload,
|
|
runId: event.runId,
|
|
seq: event.seq,
|
|
});
|
|
if (isTerminalSessionEvent(event)) {
|
|
const finalStatus = event.eventType === "done" ? "done" : "failed";
|
|
ctx.streams.emit(channel, {
|
|
type: event.eventType === "done" ? "query.done" : "query.error",
|
|
operationId: operation.operationId,
|
|
querySessionId: operation.operationId,
|
|
issueId: operation.issue.id,
|
|
sessionId: session.sessionId,
|
|
runId: event.runId,
|
|
answer,
|
|
message: event.message,
|
|
});
|
|
ctx.streams.close(channel);
|
|
void markOperation(ctx, {
|
|
companyId: input.companyId,
|
|
operationId: operation.operationId,
|
|
status: finalStatus,
|
|
runId: event.runId,
|
|
warning: event.eventType === "error" ? event.message : null,
|
|
metadata: { answerLength: answer.length },
|
|
});
|
|
void ctx.db.execute(
|
|
`UPDATE ${tableName(ctx.db.namespace, "wiki_query_sessions")}
|
|
SET status = $3,
|
|
updated_at = now()
|
|
WHERE company_id = $1 AND id = $2`,
|
|
[input.companyId, operation.operationId, finalStatus === "done" ? "completed" : "failed"],
|
|
);
|
|
void ctx.issues.createComment(
|
|
operation.issue.id,
|
|
event.eventType === "done"
|
|
? `Query completed.\n\n${answer.trim() || "_No answer text was streamed._"}`
|
|
: `Query failed: ${event.message ?? "agent session ended with an error"}`,
|
|
input.companyId,
|
|
);
|
|
void ctx.issues.update(
|
|
operation.issue.id,
|
|
{ status: event.eventType === "done" ? "done" : "blocked", originRunId: event.runId },
|
|
input.companyId,
|
|
);
|
|
}
|
|
},
|
|
});
|
|
|
|
await markOperation(ctx, {
|
|
companyId: input.companyId,
|
|
operationId: operation.operationId,
|
|
status: "running",
|
|
runId: sendResult.runId,
|
|
});
|
|
await ctx.issues.update(operation.issue.id, { originRunId: sendResult.runId }, input.companyId);
|
|
|
|
return {
|
|
status: "running",
|
|
wikiId,
|
|
spaceSlug: space.slug,
|
|
operationId: operation.operationId,
|
|
querySessionId: operation.operationId,
|
|
issue: operation.issue,
|
|
sessionId: session.sessionId,
|
|
runId: sendResult.runId,
|
|
channel,
|
|
};
|
|
}
|
|
|
|
export async function fileQueryAnswerAsPage(ctx: PluginContext, input: FileQueryAnswerInput) {
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
const space = await resolveSpace(ctx, { companyId: input.companyId, wikiId, spaceSlug: input.spaceSlug });
|
|
const path = assertPagePath(input.path);
|
|
const title = stringField(input.title) ?? inferTitle(path, input.contents ?? input.answer ?? "");
|
|
const answer = stringField(input.answer);
|
|
const contents = stringField(input.contents) ?? [
|
|
`# ${title}`,
|
|
"",
|
|
input.question ? `> Filed from query: ${input.question}` : null,
|
|
"",
|
|
answer ?? "",
|
|
].filter((line): line is string => line !== null).join("\n").trimEnd() + "\n";
|
|
const operation = await createOperationIssue(ctx, {
|
|
companyId: input.companyId,
|
|
wikiId,
|
|
spaceSlug: space.slug,
|
|
operationType: "file-as-page",
|
|
title: `File LLM Wiki answer as ${path}`,
|
|
prompt: input.question ?? answer ?? `Write ${path}`,
|
|
});
|
|
const result = await writeWikiPage(ctx, {
|
|
companyId: input.companyId,
|
|
wikiId,
|
|
spaceSlug: space.slug,
|
|
path,
|
|
contents,
|
|
expectedHash: stringField(input.expectedHash),
|
|
summary: `Filed query answer as ${path}`,
|
|
sourceRefs: input.querySessionId ? [{ querySessionId: input.querySessionId }] : [],
|
|
operationId: operation.operationId,
|
|
});
|
|
const affectedPage = {
|
|
path,
|
|
title: result.title,
|
|
pageType: result.pageType,
|
|
revisionId: result.revisionId,
|
|
};
|
|
await markOperation(ctx, {
|
|
companyId: input.companyId,
|
|
operationId: operation.operationId,
|
|
status: "done",
|
|
affectedPages: [affectedPage],
|
|
metadata: { querySessionId: input.querySessionId ?? null },
|
|
});
|
|
await ctx.issues.update(operation.issue.id, { status: "done" }, input.companyId);
|
|
await ctx.issues.createComment(
|
|
operation.issue.id,
|
|
`Filed query answer as \`${path}\`.`,
|
|
input.companyId,
|
|
);
|
|
|
|
if (input.querySessionId) {
|
|
await ctx.db.execute(
|
|
`UPDATE ${tableName(ctx.db.namespace, "wiki_query_sessions")}
|
|
SET filed_outputs = filed_outputs || $3::jsonb,
|
|
updated_at = now()
|
|
WHERE company_id = $1 AND id = $2`,
|
|
[input.companyId, input.querySessionId, jsonArrayParam([affectedPage])],
|
|
);
|
|
}
|
|
|
|
return {
|
|
status: "ok",
|
|
wikiId,
|
|
spaceSlug: space.slug,
|
|
path,
|
|
operationId: operation.operationId,
|
|
issue: operation.issue,
|
|
page: affectedPage,
|
|
};
|
|
}
|
|
|
|
export async function registerWikiTools(ctx: PluginContext) {
|
|
ctx.tools.register("wiki_search", {
|
|
displayName: "Search Wiki",
|
|
description: "Search indexed wiki page and source metadata.",
|
|
parametersSchema: ctx.manifest.tools?.find((tool) => tool.name === "wiki_search")?.parametersSchema ?? { type: "object" },
|
|
}, async (params: unknown): Promise<ToolResult> => {
|
|
const input = params as ToolParams;
|
|
const companyId = requireString(input.companyId, "companyId");
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
const space = await resolveSpace(ctx, { companyId, wikiId, spaceSlug: input.spaceSlug as string | null | undefined });
|
|
const query = requireString(input.query, "query");
|
|
const limit = normalizeLimit(input.limit, 20, 50);
|
|
const rows = await ctx.db.query<{ kind: string; path: string; title: string | null; match_text: string | null }>(
|
|
`SELECT 'page' AS kind, path, title, page_type AS match_text
|
|
FROM ${tableName(ctx.db.namespace, "wiki_pages")}
|
|
WHERE company_id = $1 AND wiki_id = $2 AND space_id = $5 AND (lower(path) LIKE lower($3) OR lower(coalesce(title, '')) LIKE lower($3))
|
|
UNION ALL
|
|
SELECT 'source' AS kind, raw_path AS path, title, source_type AS match_text
|
|
FROM ${tableName(ctx.db.namespace, "wiki_sources")}
|
|
WHERE company_id = $1 AND wiki_id = $2 AND space_id = $5 AND (lower(raw_path) LIKE lower($3) OR lower(coalesce(title, '')) LIKE lower($3) OR lower(coalesce(url, '')) LIKE lower($3))
|
|
ORDER BY kind, path
|
|
LIMIT $4`,
|
|
[companyId, wikiId, `%${query}%`, limit, space.id],
|
|
);
|
|
return {
|
|
content: rows.length ? rows.map((row) => `${row.kind}: ${row.path}${row.title ? ` - ${row.title}` : ""}`).join("\n") : "No wiki matches found.",
|
|
data: { companyId, wikiId, spaceSlug: space.slug, query, results: rows },
|
|
};
|
|
});
|
|
|
|
ctx.tools.register("wiki_read_page", {
|
|
displayName: "Read Wiki Page",
|
|
description: "Read a markdown wiki page from the configured local wiki root.",
|
|
parametersSchema: ctx.manifest.tools?.find((tool) => tool.name === "wiki_read_page")?.parametersSchema ?? { type: "object" },
|
|
}, async (params: unknown): Promise<ToolResult> => {
|
|
const input = params as ToolParams;
|
|
const companyId = requireString(input.companyId, "companyId");
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
const space = await resolveSpace(ctx, { companyId, wikiId, spaceSlug: input.spaceSlug as string | null | undefined });
|
|
const path = assertPagePath(requireString(input.path, "path"));
|
|
const contents = await ctx.localFolders.readText(companyId, WIKI_ROOT_FOLDER_KEY, spaceRelativePath(space, path));
|
|
return { content: contents, data: { companyId, wikiId, spaceSlug: space.slug, path, hash: contentHash(contents) } };
|
|
});
|
|
|
|
ctx.tools.register("wiki_write_page", {
|
|
displayName: "Write Wiki Page",
|
|
description: "Atomically write a markdown wiki page after plugin path validation.",
|
|
parametersSchema: ctx.manifest.tools?.find((tool) => tool.name === "wiki_write_page")?.parametersSchema ?? { type: "object" },
|
|
}, async (params: unknown): Promise<ToolResult> => {
|
|
const input = params as ToolParams;
|
|
const result = await writeWikiPage(ctx, {
|
|
companyId: requireString(input.companyId, "companyId"),
|
|
wikiId: stringField(input.wikiId),
|
|
spaceSlug: stringField(input.spaceSlug),
|
|
path: requireString(input.path, "path"),
|
|
contents: requireString(input.contents, "contents"),
|
|
expectedHash: stringField(input.expectedHash),
|
|
summary: stringField(input.summary),
|
|
sourceRefs: input.sourceRefs,
|
|
});
|
|
return { content: `Wrote ${result.path}`, data: result };
|
|
});
|
|
|
|
ctx.tools.register("wiki_propose_patch", {
|
|
displayName: "Propose Wiki Patch",
|
|
description: "Return a structured proposed page write without changing files.",
|
|
parametersSchema: ctx.manifest.tools?.find((tool) => tool.name === "wiki_propose_patch")?.parametersSchema ?? { type: "object" },
|
|
}, async (params: unknown): Promise<ToolResult> => {
|
|
const input = params as ToolParams;
|
|
const companyId = requireString(input.companyId, "companyId");
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
const space = await resolveSpace(ctx, { companyId, wikiId, spaceSlug: input.spaceSlug as string | null | undefined });
|
|
const path = assertPagePath(requireString(input.path, "path"));
|
|
const contents = requireString(input.contents, "contents");
|
|
const current = await readCurrentWithHash(ctx, companyId, path, space);
|
|
return {
|
|
content: `Proposed patch for ${path}`,
|
|
data: {
|
|
companyId,
|
|
wikiId,
|
|
spaceSlug: space.slug,
|
|
path,
|
|
summary: stringField(input.summary),
|
|
currentHash: current.hash,
|
|
proposedHash: contentHash(contents),
|
|
proposedContents: contents,
|
|
},
|
|
};
|
|
});
|
|
|
|
ctx.tools.register("wiki_list_sources", {
|
|
displayName: "List Wiki Sources",
|
|
description: "Return captured raw source metadata from the plugin index.",
|
|
parametersSchema: ctx.manifest.tools?.find((tool) => tool.name === "wiki_list_sources")?.parametersSchema ?? { type: "object" },
|
|
}, async (params: unknown): Promise<ToolResult> => {
|
|
const input = params as ToolParams;
|
|
const companyId = requireString(input.companyId, "companyId");
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
const space = await resolveSpace(ctx, { companyId, wikiId, spaceSlug: input.spaceSlug as string | null | undefined });
|
|
const limit = normalizeLimit(input.limit, 50, 200);
|
|
const rows = await ctx.db.query<{ raw_path: string; title: string | null; source_type: string; url: string | null; content_hash: string }>(
|
|
`SELECT raw_path, title, source_type, url, content_hash
|
|
FROM ${tableName(ctx.db.namespace, "wiki_sources")}
|
|
WHERE company_id = $1 AND wiki_id = $2 AND space_id = $4
|
|
ORDER BY created_at DESC
|
|
LIMIT $3`,
|
|
[companyId, wikiId, limit, space.id],
|
|
);
|
|
return {
|
|
content: rows.length ? rows.map((row) => `${row.raw_path}${row.title ? ` - ${row.title}` : ""}`).join("\n") : "No sources captured yet.",
|
|
data: { companyId, wikiId, spaceSlug: space.slug, sources: rows },
|
|
};
|
|
});
|
|
|
|
ctx.tools.register("wiki_read_source", {
|
|
displayName: "Read Wiki Source",
|
|
description: "Read a captured raw source from the configured local wiki root.",
|
|
parametersSchema: ctx.manifest.tools?.find((tool) => tool.name === "wiki_read_source")?.parametersSchema ?? { type: "object" },
|
|
}, async (params: unknown): Promise<ToolResult> => {
|
|
const input = params as ToolParams;
|
|
const companyId = requireString(input.companyId, "companyId");
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
const space = await resolveSpace(ctx, { companyId, wikiId, spaceSlug: input.spaceSlug as string | null | undefined });
|
|
const rawPath = assertRawPath(requireString(input.rawPath, "rawPath"));
|
|
const contents = await ctx.localFolders.readText(companyId, WIKI_ROOT_FOLDER_KEY, spaceRelativePath(space, rawPath));
|
|
return { content: contents, data: { companyId, wikiId, spaceSlug: space.slug, rawPath, hash: contentHash(contents) } };
|
|
});
|
|
|
|
ctx.tools.register("wiki_append_log", {
|
|
displayName: "Append Wiki Log",
|
|
description: "Append a maintenance note to wiki/log.md.",
|
|
parametersSchema: ctx.manifest.tools?.find((tool) => tool.name === "wiki_append_log")?.parametersSchema ?? { type: "object" },
|
|
}, async (params: unknown): Promise<ToolResult> => {
|
|
const input = params as ToolParams;
|
|
const companyId = requireString(input.companyId, "companyId");
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
const space = await resolveSpace(ctx, { companyId, wikiId, spaceSlug: input.spaceSlug as string | null | undefined });
|
|
const entry = requireString(input.entry, "entry");
|
|
let current = "";
|
|
try {
|
|
current = await ctx.localFolders.readText(companyId, WIKI_ROOT_FOLDER_KEY, spaceRelativePath(space, "wiki/log.md"));
|
|
} catch {
|
|
current = "# Log\n\nAppend-only chronological record of wiki operations.\n";
|
|
}
|
|
const next = `${current.trimEnd()}\n\n- ${new Date().toISOString()} ${entry}\n`;
|
|
await ctx.localFolders.writeTextAtomic(companyId, WIKI_ROOT_FOLDER_KEY, spaceRelativePath(space, "wiki/log.md"), next);
|
|
await upsertPageMetadata(ctx, {
|
|
companyId,
|
|
wikiId,
|
|
spaceId: space.id,
|
|
path: "wiki/log.md",
|
|
contents: next,
|
|
summary: "Append log entry",
|
|
});
|
|
return { content: "Appended log entry", data: { companyId, wikiId, spaceSlug: space.slug, hash: contentHash(next) } };
|
|
});
|
|
|
|
ctx.tools.register("wiki_update_index", {
|
|
displayName: "Update Wiki Index",
|
|
description: "Atomically replace wiki/index.md with optional hash conflict checks.",
|
|
parametersSchema: ctx.manifest.tools?.find((tool) => tool.name === "wiki_update_index")?.parametersSchema ?? { type: "object" },
|
|
}, async (params: unknown): Promise<ToolResult> => {
|
|
const input = params as ToolParams;
|
|
const result = await writeWikiPage(ctx, {
|
|
companyId: requireString(input.companyId, "companyId"),
|
|
wikiId: stringField(input.wikiId),
|
|
spaceSlug: stringField(input.spaceSlug),
|
|
path: "wiki/index.md",
|
|
contents: requireString(input.contents, "contents"),
|
|
expectedHash: stringField(input.expectedHash),
|
|
summary: "Update index",
|
|
});
|
|
return { content: "Updated wiki/index.md", data: result };
|
|
});
|
|
|
|
ctx.tools.register("wiki_list_backlinks", {
|
|
displayName: "List Wiki Backlinks",
|
|
description: "Return indexed backlinks for a wiki page.",
|
|
parametersSchema: ctx.manifest.tools?.find((tool) => tool.name === "wiki_list_backlinks")?.parametersSchema ?? { type: "object" },
|
|
}, async (params: unknown): Promise<ToolResult> => {
|
|
const input = params as ToolParams;
|
|
const companyId = requireString(input.companyId, "companyId");
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
const space = await resolveSpace(ctx, { companyId, wikiId, spaceSlug: input.spaceSlug as string | null | undefined });
|
|
const path = assertPagePath(requireString(input.path, "path"));
|
|
const rows = await ctx.db.query<{ path: string; title: string | null }>(
|
|
`SELECT path, title
|
|
FROM ${tableName(ctx.db.namespace, "wiki_pages")}
|
|
WHERE company_id = $1 AND wiki_id = $2 AND space_id = $4 AND backlinks ? $3
|
|
ORDER BY path
|
|
LIMIT 200`,
|
|
[companyId, wikiId, path, space.id],
|
|
);
|
|
return {
|
|
content: rows.length ? rows.map((row) => `${row.path}${row.title ? ` - ${row.title}` : ""}`).join("\n") : "No backlinks indexed.",
|
|
data: { companyId, wikiId, spaceSlug: space.slug, path, backlinks: rows },
|
|
};
|
|
});
|
|
|
|
ctx.tools.register("wiki_list_pages", {
|
|
displayName: "List Wiki Pages",
|
|
description: "Return the known page index from plugin metadata.",
|
|
parametersSchema: ctx.manifest.tools?.find((tool) => tool.name === "wiki_list_pages")?.parametersSchema ?? { type: "object" },
|
|
}, async (params: unknown): Promise<ToolResult> => {
|
|
const input = params as ToolParams;
|
|
const companyId = requireString(input.companyId, "companyId");
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
const space = await resolveSpace(ctx, { companyId, wikiId, spaceSlug: input.spaceSlug as string | null | undefined });
|
|
const rows = await ctx.db.query<{ path: string; title: string | null; page_type: string | null }>(
|
|
`SELECT path, title, page_type FROM ${tableName(ctx.db.namespace, "wiki_pages")} WHERE company_id = $1 AND wiki_id = $2 AND space_id = $3 ORDER BY path LIMIT 200`,
|
|
[companyId, wikiId, space.id],
|
|
);
|
|
return {
|
|
content: rows.length ? rows.map((row) => `${row.path}${row.title ? ` - ${row.title}` : ""}`).join("\n") : "No pages indexed yet.",
|
|
data: { companyId, wikiId, spaceSlug: space.slug, pages: rows },
|
|
};
|
|
});
|
|
}
|
|
|
|
export function readCompanyIdFromParams(params: Record<string, unknown>): string {
|
|
return requireString(params.companyId, "companyId");
|
|
}
|
|
|
|
const TEMPLATE_FILES = ["AGENTS.md", "IDEA.md"] as const;
|
|
type WikiTemplateFile = (typeof TEMPLATE_FILES)[number];
|
|
|
|
function isTemplateFile(value: string): value is WikiTemplateFile {
|
|
return (TEMPLATE_FILES as readonly string[]).includes(value);
|
|
}
|
|
|
|
export type WikiPageRow = {
|
|
path: string;
|
|
title: string | null;
|
|
pageType: string | null;
|
|
backlinkCount: number;
|
|
sourceCount: number;
|
|
contentHash: string | null;
|
|
updatedAt: string;
|
|
};
|
|
|
|
export type WikiSourceRow = {
|
|
rawPath: string;
|
|
title: string | null;
|
|
sourceType: string;
|
|
url: string | null;
|
|
status: string;
|
|
createdAt: string;
|
|
};
|
|
|
|
const LOCAL_BROWSE_FILE_LIMIT = 2000;
|
|
|
|
export type WikiOperationRow = {
|
|
id: string;
|
|
operationType: string;
|
|
status: string;
|
|
hiddenIssueId: string | null;
|
|
hiddenIssueIdentifier: string | null;
|
|
hiddenIssueTitle: string | null;
|
|
hiddenIssueStatus: string | null;
|
|
projectId: string | null;
|
|
runIds: unknown[];
|
|
costCents: number;
|
|
warnings: unknown[];
|
|
affectedPages: unknown[];
|
|
metadata: Record<string, unknown>;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
};
|
|
|
|
export async function listPages(ctx: PluginContext, input: {
|
|
companyId: string;
|
|
wikiId?: string | null;
|
|
spaceSlug?: string | null;
|
|
pageType?: string | null;
|
|
includeRaw?: boolean;
|
|
limit?: number | null;
|
|
}): Promise<{ pages: WikiPageRow[]; sources: WikiSourceRow[] }> {
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
const space = await resolveSpace(ctx, { companyId: input.companyId, wikiId, spaceSlug: input.spaceSlug });
|
|
const limit = normalizeLimit(input.limit, 500, LOCAL_BROWSE_FILE_LIMIT);
|
|
const params: unknown[] = [input.companyId, wikiId, space.id];
|
|
let pageFilter = "";
|
|
if (input.pageType) {
|
|
params.push(input.pageType);
|
|
pageFilter = ` AND page_type = $${params.length}`;
|
|
}
|
|
params.push(limit);
|
|
const limitIndex = params.length;
|
|
const pageRows = await ctx.db.query<{
|
|
path: string;
|
|
title: string | null;
|
|
page_type: string | null;
|
|
backlinks: unknown;
|
|
source_refs: unknown;
|
|
content_hash: string | null;
|
|
updated_at: string;
|
|
}>(
|
|
`SELECT path, title, page_type, backlinks, source_refs, content_hash, updated_at::text AS updated_at
|
|
FROM ${tableName(ctx.db.namespace, "wiki_pages")}
|
|
WHERE company_id = $1 AND wiki_id = $2 AND space_id = $3${pageFilter}
|
|
ORDER BY path
|
|
LIMIT $${limitIndex}`,
|
|
params,
|
|
);
|
|
const readablePageRows = await filterReadableRows(ctx, input.companyId, space, pageRows, (row) => row.path);
|
|
const pages: WikiPageRow[] = readablePageRows.map((row) => ({
|
|
path: row.path,
|
|
title: row.title,
|
|
pageType: row.page_type,
|
|
backlinkCount: Array.isArray(row.backlinks) ? row.backlinks.length : 0,
|
|
sourceCount: Array.isArray(row.source_refs) ? row.source_refs.length : 0,
|
|
contentHash: row.content_hash,
|
|
updatedAt: row.updated_at,
|
|
}));
|
|
let pagesWithLocalFiles = pages;
|
|
if (!input.pageType) {
|
|
const wikiFiles = await listLocalFiles(ctx, { companyId: input.companyId, space, relativePath: "wiki" });
|
|
pagesWithLocalFiles = mergeLocalPageRows(pages, wikiFiles);
|
|
}
|
|
|
|
let sources: WikiSourceRow[] = [];
|
|
if (input.includeRaw) {
|
|
sources = (await listSources(ctx, { companyId: input.companyId, wikiId, spaceSlug: space.slug, limit, onlyReadable: true })).sources;
|
|
sources = mergeLocalSourceRows(sources, await listLocalFiles(ctx, { companyId: input.companyId, space, relativePath: "raw" }));
|
|
}
|
|
return { pages: pagesWithLocalFiles, sources };
|
|
}
|
|
|
|
export async function listSources(ctx: PluginContext, input: {
|
|
companyId: string;
|
|
wikiId?: string | null;
|
|
spaceSlug?: string | null;
|
|
limit?: number | null;
|
|
onlyReadable?: boolean;
|
|
}): Promise<{ sources: WikiSourceRow[] }> {
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
const space = await resolveSpace(ctx, { companyId: input.companyId, wikiId, spaceSlug: input.spaceSlug });
|
|
const limit = normalizeLimit(input.limit, 500, LOCAL_BROWSE_FILE_LIMIT);
|
|
const rows = await ctx.db.query<{ raw_path: string; title: string | null; source_type: string; url: string | null; status: string; created_at: string }>(
|
|
`SELECT raw_path, title, source_type, url, status, created_at::text AS created_at
|
|
FROM ${tableName(ctx.db.namespace, "wiki_sources")}
|
|
WHERE company_id = $1 AND wiki_id = $2 AND space_id = $4
|
|
ORDER BY created_at DESC
|
|
LIMIT $3`,
|
|
[input.companyId, wikiId, limit, space.id],
|
|
);
|
|
const sourceRows = input.onlyReadable
|
|
? await filterReadableRows(ctx, input.companyId, space, rows, (row) => row.raw_path)
|
|
: rows;
|
|
return {
|
|
sources: sourceRows.map((row) => ({
|
|
rawPath: row.raw_path,
|
|
title: row.title,
|
|
sourceType: row.source_type,
|
|
url: row.url,
|
|
status: row.status,
|
|
createdAt: row.created_at,
|
|
})),
|
|
};
|
|
}
|
|
|
|
export async function readWikiPage(ctx: PluginContext, input: { companyId: string; wikiId?: string | null; spaceSlug?: string | null; path: string }) {
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
const space = await resolveSpace(ctx, { companyId: input.companyId, wikiId, spaceSlug: input.spaceSlug });
|
|
const path = assertWikiPath(input.path);
|
|
const contents = await ctx.localFolders.readText(input.companyId, WIKI_ROOT_FOLDER_KEY, spaceRelativePath(space, path));
|
|
const meta = await ctx.db.query<{ title: string | null; page_type: string | null; backlinks: unknown; source_refs: unknown; updated_at: string }>(
|
|
`SELECT title, page_type, backlinks, source_refs, updated_at::text AS updated_at
|
|
FROM ${tableName(ctx.db.namespace, "wiki_pages")}
|
|
WHERE company_id = $1 AND wiki_id = $2 AND space_id = $4 AND path = $3
|
|
LIMIT 1`,
|
|
[input.companyId, wikiId, path, space.id],
|
|
);
|
|
const row = meta[0] ?? null;
|
|
return {
|
|
wikiId,
|
|
spaceSlug: space.slug,
|
|
path,
|
|
contents,
|
|
title: row?.title ?? inferTitle(path, contents),
|
|
pageType: row?.page_type ?? inferPageType(path),
|
|
backlinks: Array.isArray(row?.backlinks) ? row?.backlinks : [],
|
|
sourceRefs: Array.isArray(row?.source_refs) ? row?.source_refs : [],
|
|
updatedAt: row?.updated_at ?? null,
|
|
hash: contentHash(contents),
|
|
};
|
|
}
|
|
|
|
export async function readTemplate(ctx: PluginContext, input: { companyId: string; path: string }) {
|
|
if (!isTemplateFile(input.path)) {
|
|
throw new Error(`template path must be one of ${TEMPLATE_FILES.join(", ")}`);
|
|
}
|
|
try {
|
|
const contents = await ctx.localFolders.readText(input.companyId, WIKI_ROOT_FOLDER_KEY, input.path);
|
|
return { path: input.path, contents, hash: contentHash(contents), exists: true };
|
|
} catch (error) {
|
|
return { path: input.path, contents: "", hash: null, exists: false, error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
}
|
|
|
|
export async function writeTemplate(ctx: PluginContext, input: { companyId: string; path: string; contents: string }) {
|
|
if (!isTemplateFile(input.path)) {
|
|
throw new Error(`template path must be one of ${TEMPLATE_FILES.join(", ")}`);
|
|
}
|
|
await ctx.localFolders.writeTextAtomic(input.companyId, WIKI_ROOT_FOLDER_KEY, input.path, input.contents);
|
|
return { status: "ok", path: input.path, hash: contentHash(input.contents) };
|
|
}
|
|
|
|
export type DistillationCursorRow = {
|
|
id: string;
|
|
sourceScope: string;
|
|
scopeKey: string;
|
|
projectId: string | null;
|
|
projectName: string | null;
|
|
projectColor: string | null;
|
|
rootIssueId: string | null;
|
|
rootIssueIdentifier: string | null;
|
|
rootIssueTitle: string | null;
|
|
lastProcessedAt: string | null;
|
|
lastObservedAt: string | null;
|
|
pendingEventCount: number;
|
|
lastSourceHash: string | null;
|
|
lastSuccessfulRunId: string | null;
|
|
};
|
|
|
|
export type DistillationRunRow = {
|
|
id: string;
|
|
cursorId: string | null;
|
|
workItemId: string | null;
|
|
projectId: string | null;
|
|
projectName: string | null;
|
|
rootIssueId: string | null;
|
|
rootIssueIdentifier: string | null;
|
|
sourceWindowStart: string | null;
|
|
sourceWindowEnd: string | null;
|
|
sourceHash: string | null;
|
|
status: string;
|
|
costCents: number;
|
|
retryCount: number;
|
|
warnings: string[];
|
|
metadata: Record<string, unknown>;
|
|
operationIssueId: string | null;
|
|
operationIssueIdentifier: string | null;
|
|
operationIssueTitle: string | null;
|
|
affectedPagePaths: string[];
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
};
|
|
|
|
export type DistillationWorkItemRow = {
|
|
id: string;
|
|
workItemKind: string;
|
|
status: string;
|
|
priority: string;
|
|
projectId: string | null;
|
|
rootIssueId: string | null;
|
|
metadata: Record<string, unknown>;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
};
|
|
|
|
export type DistillationPageBindingRow = {
|
|
id: string;
|
|
pagePath: string;
|
|
projectId: string | null;
|
|
projectName: string | null;
|
|
rootIssueId: string | null;
|
|
lastAppliedSourceHash: string | null;
|
|
lastDistillationRunId: string | null;
|
|
lastRunStatus: string | null;
|
|
lastRunCompletedAt: string | null;
|
|
lastRunSourceWindowEnd: string | null;
|
|
lastRunSourceHash: string | null;
|
|
metadata: Record<string, unknown>;
|
|
updatedAt: string;
|
|
};
|
|
|
|
export type DistillationSourceSnapshotRow = {
|
|
id: string;
|
|
distillationRunId: string;
|
|
sourceHash: string;
|
|
maxCharacters: number;
|
|
clipped: boolean;
|
|
sourceRefs: unknown[];
|
|
metadata: Record<string, unknown>;
|
|
createdAt: string;
|
|
};
|
|
|
|
export type DistillationOverview = {
|
|
cursors: DistillationCursorRow[];
|
|
runs: DistillationRunRow[];
|
|
workItems: DistillationWorkItemRow[];
|
|
pageBindings: DistillationPageBindingRow[];
|
|
reviewWorkItems: DistillationWorkItemRow[];
|
|
counts: {
|
|
cursors: number;
|
|
runningRuns: number;
|
|
failedRuns24h: number;
|
|
reviewRequired: number;
|
|
};
|
|
};
|
|
|
|
function jsonObject(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
|
}
|
|
|
|
function jsonArray(value: unknown): unknown[] {
|
|
return Array.isArray(value) ? value : [];
|
|
}
|
|
|
|
function affectedPagePathsFromRunMetadata(metadata: Record<string, unknown>, fallbackBindings: DistillationPageBindingRow[], runId: string): string[] {
|
|
const explicit = jsonArray(metadata.affectedPages ?? metadata.pagePaths ?? metadata.affected_pages)
|
|
.map((entry) => {
|
|
if (typeof entry === "string") return entry;
|
|
if (entry && typeof entry === "object") {
|
|
const path = (entry as Record<string, unknown>).path;
|
|
return typeof path === "string" ? path : null;
|
|
}
|
|
return null;
|
|
})
|
|
.filter((value): value is string => Boolean(value));
|
|
if (explicit.length > 0) return Array.from(new Set(explicit));
|
|
const bindings = fallbackBindings
|
|
.filter((binding) => binding.lastDistillationRunId === runId)
|
|
.map((binding) => binding.pagePath);
|
|
return Array.from(new Set(bindings));
|
|
}
|
|
|
|
export async function getDistillationOverview(ctx: PluginContext, input: {
|
|
companyId: string;
|
|
wikiId?: string | null;
|
|
spaceSlug?: string | null;
|
|
limit?: number | null;
|
|
}): Promise<DistillationOverview> {
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
const space = await resolveSpace(ctx, { companyId: input.companyId, wikiId, spaceSlug: input.spaceSlug });
|
|
const runLimit = normalizeLimit(input.limit ?? 25, 25, 200);
|
|
const cursorRows = await ctx.db.query<{
|
|
id: string;
|
|
source_scope: string;
|
|
scope_key: string;
|
|
project_id: string | null;
|
|
project_name: string | null;
|
|
project_color: string | null;
|
|
root_issue_id: string | null;
|
|
root_issue_identifier: string | null;
|
|
root_issue_title: string | null;
|
|
last_processed_at: string | null;
|
|
last_observed_at: string | null;
|
|
pending_event_count: number;
|
|
last_source_hash: string | null;
|
|
last_successful_run_id: string | null;
|
|
}>(
|
|
`SELECT cursor.id,
|
|
cursor.source_scope,
|
|
cursor.scope_key,
|
|
cursor.project_id,
|
|
project.name AS project_name,
|
|
project.color AS project_color,
|
|
cursor.root_issue_id,
|
|
issue.identifier AS root_issue_identifier,
|
|
issue.title AS root_issue_title,
|
|
cursor.last_processed_at::text AS last_processed_at,
|
|
cursor.last_observed_at::text AS last_observed_at,
|
|
cursor.pending_event_count,
|
|
cursor.last_source_hash,
|
|
cursor.last_successful_run_id
|
|
FROM ${distillationCursorTable(ctx)} cursor
|
|
LEFT JOIN public.projects project ON project.id = cursor.project_id
|
|
LEFT JOIN public.issues issue ON issue.id = cursor.root_issue_id
|
|
WHERE cursor.company_id = $1 AND cursor.wiki_id = $2 AND cursor.space_id = $3
|
|
ORDER BY cursor.updated_at DESC
|
|
LIMIT 200`,
|
|
[input.companyId, wikiId, space.id],
|
|
);
|
|
|
|
const runRows = await ctx.db.query<{
|
|
id: string;
|
|
cursor_id: string | null;
|
|
work_item_id: string | null;
|
|
project_id: string | null;
|
|
project_name: string | null;
|
|
root_issue_id: string | null;
|
|
root_issue_identifier: string | null;
|
|
source_window_start: string | null;
|
|
source_window_end: string | null;
|
|
source_hash: string | null;
|
|
status: string;
|
|
cost_cents: number;
|
|
retry_count: number;
|
|
warnings: unknown;
|
|
metadata: unknown;
|
|
operation_issue_id: string | null;
|
|
operation_issue_identifier: string | null;
|
|
operation_issue_title: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}>(
|
|
`SELECT run.id,
|
|
run.cursor_id,
|
|
run.work_item_id,
|
|
run.project_id,
|
|
project.name AS project_name,
|
|
run.root_issue_id,
|
|
root_issue.identifier AS root_issue_identifier,
|
|
run.source_window_start::text AS source_window_start,
|
|
run.source_window_end::text AS source_window_end,
|
|
run.source_hash,
|
|
run.status,
|
|
run.cost_cents,
|
|
run.retry_count,
|
|
run.warnings,
|
|
run.metadata,
|
|
run.operation_issue_id,
|
|
op_issue.identifier AS operation_issue_identifier,
|
|
op_issue.title AS operation_issue_title,
|
|
run.created_at::text AS created_at,
|
|
run.updated_at::text AS updated_at
|
|
FROM ${distillationRunTable(ctx)} run
|
|
LEFT JOIN public.projects project ON project.id = run.project_id
|
|
LEFT JOIN public.issues root_issue ON root_issue.id = run.root_issue_id
|
|
LEFT JOIN public.issues op_issue ON op_issue.id = run.operation_issue_id
|
|
WHERE run.company_id = $1 AND run.wiki_id = $2 AND run.space_id = $4
|
|
ORDER BY run.created_at DESC
|
|
LIMIT $3`,
|
|
[input.companyId, wikiId, runLimit, space.id],
|
|
);
|
|
|
|
const workItemRows = await ctx.db.query<{
|
|
id: string;
|
|
work_item_kind: string;
|
|
status: string;
|
|
priority: string;
|
|
project_id: string | null;
|
|
root_issue_id: string | null;
|
|
metadata: unknown;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}>(
|
|
`SELECT id, work_item_kind, status, priority, project_id, root_issue_id, metadata,
|
|
created_at::text AS created_at, updated_at::text AS updated_at
|
|
FROM ${distillationWorkItemTable(ctx)}
|
|
WHERE company_id = $1 AND wiki_id = $2 AND space_id = $3 AND status IN ('pending', 'review_required', 'in_progress', 'failed')
|
|
ORDER BY created_at DESC
|
|
LIMIT 100`,
|
|
[input.companyId, wikiId, space.id],
|
|
);
|
|
|
|
const bindingRows = await ctx.db.query<{
|
|
id: string;
|
|
page_path: string;
|
|
project_id: string | null;
|
|
project_name: string | null;
|
|
root_issue_id: string | null;
|
|
last_applied_source_hash: string | null;
|
|
last_distillation_run_id: string | null;
|
|
last_run_status: string | null;
|
|
last_run_completed_at: string | null;
|
|
last_run_source_window_end: string | null;
|
|
last_run_source_hash: string | null;
|
|
metadata: unknown;
|
|
updated_at: string;
|
|
}>(
|
|
`SELECT binding.id,
|
|
binding.page_path,
|
|
binding.project_id,
|
|
project.name AS project_name,
|
|
binding.root_issue_id,
|
|
binding.last_applied_source_hash,
|
|
binding.last_distillation_run_id,
|
|
run.status::text AS last_run_status,
|
|
run.updated_at::text AS last_run_completed_at,
|
|
run.source_window_end::text AS last_run_source_window_end,
|
|
run.source_hash AS last_run_source_hash,
|
|
binding.metadata,
|
|
binding.updated_at::text AS updated_at
|
|
FROM ${pageBindingTable(ctx)} binding
|
|
LEFT JOIN public.projects project ON project.id = binding.project_id
|
|
LEFT JOIN ${distillationRunTable(ctx)} run ON run.id = binding.last_distillation_run_id
|
|
WHERE binding.company_id = $1 AND binding.wiki_id = $2 AND binding.space_id = $3
|
|
ORDER BY binding.updated_at DESC
|
|
LIMIT 200`,
|
|
[input.companyId, wikiId, space.id],
|
|
);
|
|
|
|
const cursors: DistillationCursorRow[] = cursorRows.map((row) => ({
|
|
id: row.id,
|
|
sourceScope: row.source_scope,
|
|
scopeKey: row.scope_key,
|
|
projectId: row.project_id,
|
|
projectName: row.project_name,
|
|
projectColor: row.project_color,
|
|
rootIssueId: row.root_issue_id,
|
|
rootIssueIdentifier: row.root_issue_identifier,
|
|
rootIssueTitle: row.root_issue_title,
|
|
lastProcessedAt: row.last_processed_at,
|
|
lastObservedAt: row.last_observed_at,
|
|
pendingEventCount: Number(row.pending_event_count ?? 0),
|
|
lastSourceHash: row.last_source_hash,
|
|
lastSuccessfulRunId: row.last_successful_run_id,
|
|
}));
|
|
|
|
const pageBindings: DistillationPageBindingRow[] = bindingRows.map((row) => ({
|
|
id: row.id,
|
|
pagePath: row.page_path,
|
|
projectId: row.project_id,
|
|
projectName: row.project_name,
|
|
rootIssueId: row.root_issue_id,
|
|
lastAppliedSourceHash: row.last_applied_source_hash,
|
|
lastDistillationRunId: row.last_distillation_run_id,
|
|
lastRunStatus: row.last_run_status,
|
|
lastRunCompletedAt: row.last_run_completed_at,
|
|
lastRunSourceWindowEnd: row.last_run_source_window_end,
|
|
lastRunSourceHash: row.last_run_source_hash,
|
|
metadata: jsonObject(row.metadata),
|
|
updatedAt: row.updated_at,
|
|
}));
|
|
|
|
const runs: DistillationRunRow[] = runRows.map((row) => {
|
|
const metadata = jsonObject(row.metadata);
|
|
return {
|
|
id: row.id,
|
|
cursorId: row.cursor_id,
|
|
workItemId: row.work_item_id,
|
|
projectId: row.project_id,
|
|
projectName: row.project_name,
|
|
rootIssueId: row.root_issue_id,
|
|
rootIssueIdentifier: row.root_issue_identifier,
|
|
sourceWindowStart: row.source_window_start,
|
|
sourceWindowEnd: row.source_window_end,
|
|
sourceHash: row.source_hash,
|
|
status: row.status,
|
|
costCents: Number(row.cost_cents ?? 0),
|
|
retryCount: Number(row.retry_count ?? 0),
|
|
warnings: jsonArray(row.warnings).map((entry) => (typeof entry === "string" ? entry : JSON.stringify(entry))),
|
|
metadata,
|
|
operationIssueId: row.operation_issue_id,
|
|
operationIssueIdentifier: row.operation_issue_identifier,
|
|
operationIssueTitle: row.operation_issue_title,
|
|
affectedPagePaths: affectedPagePathsFromRunMetadata(metadata, pageBindings, row.id),
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
};
|
|
});
|
|
|
|
const workItems: DistillationWorkItemRow[] = workItemRows.map((row) => ({
|
|
id: row.id,
|
|
workItemKind: row.work_item_kind,
|
|
status: row.status,
|
|
priority: row.priority,
|
|
projectId: row.project_id,
|
|
rootIssueId: row.root_issue_id,
|
|
metadata: jsonObject(row.metadata),
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
}));
|
|
|
|
const reviewWorkItems = workItems.filter((item) => item.status === "review_required" || item.workItemKind === "review");
|
|
const failedSince = Date.now() - 24 * 60 * 60 * 1000;
|
|
const failedRuns24h = runs.filter((run) => {
|
|
if (run.status !== "failed" && run.status !== "refused_cost_cap") return false;
|
|
const updatedAt = run.updatedAt ? Date.parse(run.updatedAt) : Number.NaN;
|
|
return Number.isFinite(updatedAt) ? updatedAt >= failedSince : true;
|
|
}).length;
|
|
|
|
return {
|
|
cursors,
|
|
runs,
|
|
workItems,
|
|
pageBindings,
|
|
reviewWorkItems,
|
|
counts: {
|
|
cursors: cursors.length,
|
|
runningRuns: runs.filter((run) => run.status === "running").length,
|
|
failedRuns24h,
|
|
reviewRequired: reviewWorkItems.length,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function getDistillationPageProvenance(ctx: PluginContext, input: {
|
|
companyId: string;
|
|
wikiId?: string | null;
|
|
spaceSlug?: string | null;
|
|
pagePath: string;
|
|
}): Promise<{
|
|
binding: DistillationPageBindingRow | null;
|
|
runs: DistillationRunRow[];
|
|
snapshot: DistillationSourceSnapshotRow | null;
|
|
cursor: DistillationCursorRow | null;
|
|
}> {
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
const space = await resolveSpace(ctx, { companyId: input.companyId, wikiId, spaceSlug: input.spaceSlug });
|
|
const overview = await getDistillationOverview(ctx, { companyId: input.companyId, wikiId, spaceSlug: space.slug });
|
|
const binding = overview.pageBindings.find((row) => row.pagePath === input.pagePath) ?? null;
|
|
if (!binding) {
|
|
return { binding: null, runs: [], snapshot: null, cursor: null };
|
|
}
|
|
const relatedRuns = overview.runs.filter((run) => {
|
|
if (binding.lastDistillationRunId === run.id) return true;
|
|
if (binding.projectId && run.projectId === binding.projectId) return true;
|
|
if (binding.rootIssueId && run.rootIssueId === binding.rootIssueId) return true;
|
|
return run.affectedPagePaths.includes(binding.pagePath);
|
|
});
|
|
const cursor = overview.cursors.find((row) => {
|
|
if (binding.rootIssueId && row.rootIssueId === binding.rootIssueId) return true;
|
|
if (binding.projectId && row.projectId === binding.projectId) return true;
|
|
return false;
|
|
}) ?? null;
|
|
|
|
let snapshot: DistillationSourceSnapshotRow | null = null;
|
|
if (binding.lastDistillationRunId) {
|
|
const snapshotRows = await ctx.db.query<{
|
|
id: string;
|
|
distillation_run_id: string;
|
|
source_hash: string;
|
|
max_characters: number;
|
|
clipped: boolean;
|
|
source_refs: unknown;
|
|
metadata: unknown;
|
|
created_at: string;
|
|
}>(
|
|
`SELECT id, distillation_run_id, source_hash, max_characters, clipped, source_refs, metadata, created_at::text AS created_at
|
|
FROM ${sourceSnapshotTable(ctx)}
|
|
WHERE company_id = $1 AND wiki_id = $2 AND space_id = $4 AND distillation_run_id = $3
|
|
ORDER BY created_at DESC
|
|
LIMIT 1`,
|
|
[input.companyId, wikiId, binding.lastDistillationRunId, space.id],
|
|
);
|
|
if (snapshotRows[0]) {
|
|
const row = snapshotRows[0];
|
|
snapshot = {
|
|
id: row.id,
|
|
distillationRunId: row.distillation_run_id,
|
|
sourceHash: row.source_hash,
|
|
maxCharacters: Number(row.max_characters ?? 0),
|
|
clipped: Boolean(row.clipped),
|
|
sourceRefs: jsonArray(row.source_refs),
|
|
metadata: jsonObject(row.metadata),
|
|
createdAt: row.created_at,
|
|
};
|
|
}
|
|
}
|
|
|
|
return { binding, runs: relatedRuns, snapshot, cursor };
|
|
}
|
|
|
|
export async function listOperations(ctx: PluginContext, input: {
|
|
companyId: string;
|
|
wikiId?: string | null;
|
|
spaceSlug?: string | null;
|
|
operationType?: string | null;
|
|
status?: string | null;
|
|
limit?: number | null;
|
|
}): Promise<{ operations: WikiOperationRow[] }> {
|
|
const wikiId = normalizeWikiId(input.wikiId);
|
|
const space = await resolveSpace(ctx, { companyId: input.companyId, wikiId, spaceSlug: input.spaceSlug });
|
|
const limit = normalizeLimit(input.limit, 50, 500);
|
|
const params: unknown[] = [input.companyId, wikiId, space.id];
|
|
const filters: string[] = [];
|
|
if (input.operationType && input.operationType !== "all") {
|
|
params.push(input.operationType);
|
|
filters.push(`op.operation_type = $${params.length}`);
|
|
}
|
|
if (input.status && input.status !== "all") {
|
|
params.push(input.status);
|
|
filters.push(`op.status = $${params.length}`);
|
|
}
|
|
params.push(limit);
|
|
const filterSql = filters.length ? ` AND ${filters.join(" AND ")}` : "";
|
|
const rows = await ctx.db.query<{
|
|
id: string;
|
|
operation_type: string;
|
|
status: string;
|
|
hidden_issue_id: string | null;
|
|
hidden_issue_identifier: string | null;
|
|
hidden_issue_title: string | null;
|
|
hidden_issue_status: string | null;
|
|
project_id: string | null;
|
|
run_ids: unknown;
|
|
cost_cents: number;
|
|
warnings: unknown;
|
|
affected_pages: unknown;
|
|
metadata: unknown;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}>(
|
|
`SELECT op.id, op.operation_type, op.status, op.hidden_issue_id, op.project_id,
|
|
op.run_ids, op.cost_cents, op.warnings, op.affected_pages, op.metadata,
|
|
op.created_at::text AS created_at, op.updated_at::text AS updated_at,
|
|
issue.identifier AS hidden_issue_identifier,
|
|
issue.title AS hidden_issue_title,
|
|
issue.status::text AS hidden_issue_status
|
|
FROM ${tableName(ctx.db.namespace, "wiki_operations")} op
|
|
LEFT JOIN public.issues issue ON issue.id = op.hidden_issue_id
|
|
WHERE op.company_id = $1 AND op.wiki_id = $2 AND op.space_id = $3${filterSql}
|
|
ORDER BY op.created_at DESC
|
|
LIMIT $${params.length}`,
|
|
params,
|
|
);
|
|
return {
|
|
operations: rows.map((row) => ({
|
|
id: row.id,
|
|
operationType: row.operation_type,
|
|
status: row.status,
|
|
hiddenIssueId: row.hidden_issue_id,
|
|
hiddenIssueIdentifier: row.hidden_issue_identifier,
|
|
hiddenIssueTitle: row.hidden_issue_title,
|
|
hiddenIssueStatus: row.hidden_issue_status,
|
|
projectId: row.project_id,
|
|
runIds: Array.isArray(row.run_ids) ? row.run_ids : [],
|
|
costCents: Number(row.cost_cents ?? 0),
|
|
warnings: Array.isArray(row.warnings) ? row.warnings : [],
|
|
affectedPages: Array.isArray(row.affected_pages) ? row.affected_pages : [],
|
|
metadata: jsonObject(row.metadata),
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
})),
|
|
};
|
|
}
|