import { definePlugin, runWorker } from "@paperclipai/plugin-sdk"; import type { PluginContext, PluginWebhookInput } from "@paperclipai/plugin-sdk"; import { LegalApi } from "./legal-api.js"; // Hoisted so onWebhook can access the context after setup() completes. let pluginCtx: PluginContext | null = null; // Per-company CEO agent IDs (shared between setup and onWebhook). const CEO_AGENT_IDS: Record = { "42a7acd0-30c5-4cbd-ac97-7424f65df294": "752cebdd-6748-4a04-aacd-c7ab0294ef33", // CMP (רישוי ובניה) "8639e837-4c9d-47fa-a76b-95788d651896": "cdbfa8bc-3d61-41a4-a2e7-677ec7d34562", // CMPA (היטלי השבחה) }; const plugin = definePlugin({ async setup(ctx) { pluginCtx = ctx; // save for onWebhook const config = (await ctx.config.get()) as { legalApiBaseUrl?: string; } | null; const baseUrl = config?.legalApiBaseUrl || "http://localhost:8085"; const api = new LegalApi(baseUrl); ctx.logger.info("Legal AI plugin starting", { url: baseUrl }); // ── Tools ────────────────────────────────────────────────────── ctx.tools.register( "legal_case_list", { displayName: "רשימת תיקי ערר", description: "List all appeal cases in the legal system. Returns case number, title, and status (new/in_progress/drafted/reviewed/final).", parametersSchema: { type: "object", properties: {}, }, }, async () => { const cases = await api.listCases(); return { content: JSON.stringify(cases, null, 2), data: cases, }; }, ); ctx.tools.register( "legal_case_get", { displayName: "פרטי תיק ערר", description: "Get full details of a legal case including documents list. Provide the case number (e.g. 123/24).", parametersSchema: { type: "object", properties: { case_number: { type: "string", description: "Case number (e.g. 123/24)", }, }, required: ["case_number"], }, }, async (params) => { const { case_number } = params as { case_number: string }; const result = await api.getCase(case_number); return { content: JSON.stringify(result, null, 2), data: result, }; }, ); ctx.tools.register( "legal_case_create", { displayName: "יצירת תיק ערר", description: "Create a new appeal case. Case numbers: 1xxx=licensing, 8xxx=betterment levy, 9xxx=compensation. Also creates a linked Paperclip issue.", parametersSchema: { type: "object", properties: { case_number: { type: "string", description: "Case number (e.g. 1234/24)", }, title: { type: "string", description: "Case title" }, appellants: { type: "array", items: { type: "string" }, description: "Appellant names", }, respondents: { type: "array", items: { type: "string" }, description: "Respondent names", }, subject: { type: "string", description: "Case subject" }, property_address: { type: "string", description: "Property address", }, expected_outcome: { type: "string", enum: [ "rejection", "partial_acceptance", "full_acceptance", "betterment_levy", ], description: "Expected outcome type", }, }, required: ["case_number", "title"], }, }, async (params, runCtx) => { const input = params as { case_number: string; title: string; appellants?: string[]; respondents?: string[]; subject?: string; property_address?: string; expected_outcome?: string; }; // Create case in legal-ai const legalCase = await api.createCase(input); // Create linked Paperclip issue const issue = await ctx.issues.create({ companyId: runCtx.companyId, title: `[ערר ${input.case_number}] ${input.title}`, description: `תיק ערר חדש\nנושא: ${input.subject || ""}\nתוצאה צפויה: ${input.expected_outcome || "לא הוגדרה"}`, }); // Store mapping in plugin state await ctx.state.set( { scopeKind: "issue", scopeId: issue.id, stateKey: "legal-case-number", }, input.case_number, ); await ctx.activity.log({ companyId: runCtx.companyId, message: `נוצר תיק ערר ${input.case_number} וקושר ל-issue ${issue.id}`, }); return { content: `Case ${input.case_number} created and linked to Paperclip issue.\n\n${JSON.stringify(legalCase, null, 2)}`, data: { legalCase, issueId: issue.id }, }; }, ); ctx.tools.register( "legal_case_update", { displayName: "עדכון תיק ערר", description: "Update a legal case's status, title, subject, or expected outcome.", parametersSchema: { type: "object", properties: { case_number: { type: "string", description: "Case number (e.g. 123/24)", }, status: { type: "string", enum: ["new", "in_progress", "drafted", "reviewed", "final"], description: "New case status", }, title: { type: "string" }, subject: { type: "string" }, notes: { type: "string" }, expected_outcome: { type: "string", enum: [ "rejection", "partial_acceptance", "full_acceptance", "betterment_levy", ], }, }, required: ["case_number"], }, }, async (params) => { const { case_number, ...updates } = params as { case_number: string; status?: string; title?: string; subject?: string; notes?: string; expected_outcome?: string; }; const result = await api.updateCase(case_number, updates); return { content: JSON.stringify(result, null, 2), data: result, }; }, ); ctx.tools.register( "legal_case_status", { displayName: "סטטוס תהליך עבודה", description: "Get full workflow status for a case: documents processed, chunks created, draft progress, and suggested next steps.", parametersSchema: { type: "object", properties: { case_number: { type: "string", description: "Case number (e.g. 123/24)", }, }, required: ["case_number"], }, }, async (params) => { const { case_number } = params as { case_number: string }; const result = await api.getCaseStatus(case_number); return { content: JSON.stringify(result, null, 2), data: result, }; }, ); ctx.tools.register( "legal_search", { displayName: "חיפוש תקדימים משפטיים", description: "Semantic search (RAG) across previous decisions and documents. Query in Hebrew for best results.", parametersSchema: { type: "object", properties: { query: { type: "string", description: "Search query in Hebrew", }, limit: { type: "number", description: "Max results (default 10)" }, section_type: { type: "string", description: "Filter by section type: facts, legal_analysis, conclusion, ruling", }, }, required: ["query"], }, }, async (params) => { const { query, limit, section_type } = params as { query: string; limit?: number; section_type?: string; }; const results = await api.search( query, limit || 10, section_type || "", ); return { content: JSON.stringify(results, null, 2), data: results, }; }, ); ctx.tools.register( "legal_case_template", { displayName: "תבנית החלטה", description: "Get an outcome-aware decision template for a case, with guidance for the 12-block structure.", parametersSchema: { type: "object", properties: { case_number: { type: "string", description: "Case number (e.g. 123/24)", }, }, required: ["case_number"], }, }, async (params) => { const { case_number } = params as { case_number: string }; const result = await api.getTemplate(case_number); return { content: result.template, data: result, }; }, ); ctx.tools.register( "legal_processing_status", { displayName: "סטטוס עיבוד כללי", description: "Get overall processing status: total cases, documents, pending processing, chunks, and style corpus entries.", parametersSchema: { type: "object", properties: {}, }, }, async () => { const result = await api.getProcessingStatus(); return { content: JSON.stringify(result, null, 2), data: result, }; }, ); // ── New Tools (Phase 3) ───────────────────────────────────────── ctx.tools.register( "legal_document_list", { displayName: "רשימת מסמכים בתיק", description: "List all documents in a case with their extraction status.", parametersSchema: { type: "object", properties: { case_number: { type: "string", description: "Case number" }, }, required: ["case_number"], }, }, async (params) => { const { case_number } = params as { case_number: string }; const docs = await api.listDocuments(case_number); return { content: JSON.stringify(docs, null, 2), data: docs }; }, ); ctx.tools.register( "legal_set_outcome", { displayName: "הזנת תוצאת ערר", description: "Set the decision outcome (rejection/full_acceptance/partial_acceptance) and optional reasoning from Dafna.", parametersSchema: { type: "object", properties: { case_number: { type: "string", description: "Case number" }, outcome: { type: "string", enum: ["rejection", "full_acceptance", "partial_acceptance"], description: "Decision outcome", }, reasoning: { type: "string", description: "Optional reasoning from Dafna", }, }, required: ["case_number", "outcome"], }, }, async (params) => { const { case_number, outcome, reasoning } = params as { case_number: string; outcome: string; reasoning?: string; }; const result = await api.setOutcome(case_number, outcome, reasoning); return { content: JSON.stringify(result, null, 2), data: result }; }, ); ctx.tools.register( "legal_get_claims", { displayName: "טענות מחולצות", description: "Get extracted claims for a case, grouped by party role.", parametersSchema: { type: "object", properties: { case_number: { type: "string", description: "Case number" }, }, required: ["case_number"], }, }, async (params) => { const { case_number } = params as { case_number: string }; const result = await api.getClaims(case_number); return { content: JSON.stringify(result, null, 2), data: result }; }, ); ctx.tools.register( "legal_search_case", { displayName: "חיפוש בתוך תיק", description: "Semantic search within a specific case's documents.", parametersSchema: { type: "object", properties: { case_number: { type: "string", description: "Case number" }, query: { type: "string", description: "Search query in Hebrew" }, }, required: ["case_number", "query"], }, }, async (params) => { const { case_number, query } = params as { case_number: string; query: string; }; const results = await api.searchCase(case_number, query); return { content: JSON.stringify(results, null, 2), data: results }; }, ); ctx.tools.register( "legal_find_similar", { displayName: "תקדימים דומים", description: "Find similar cases/precedents for a given case.", parametersSchema: { type: "object", properties: { case_number: { type: "string", description: "Case number" }, }, required: ["case_number"], }, }, async (params) => { const { case_number } = params as { case_number: string }; const results = await api.findSimilarCases(case_number); return { content: JSON.stringify(results, null, 2), data: results }; }, ); ctx.tools.register( "legal_run_qa", { displayName: "בדיקת איכות", description: "Run QA validation on a drafted decision. Checks: grounding, claims coverage, neutral background, weights.", parametersSchema: { type: "object", properties: { case_number: { type: "string", description: "Case number" }, }, required: ["case_number"], }, }, async (params) => { const { case_number } = params as { case_number: string }; const result = await api.runQA(case_number); return { content: JSON.stringify(result, null, 2), data: result }; }, ); ctx.tools.register( "legal_trigger_learning", { displayName: "הפעלת לולאת למידה", description: "Trigger the learning loop — compare draft to final signed version.", parametersSchema: { type: "object", properties: { case_number: { type: "string", description: "Case number" }, }, required: ["case_number"], }, }, async (params) => { const { case_number } = params as { case_number: string }; const result = await api.triggerLearning(case_number); return { content: JSON.stringify(result, null, 2), data: result }; }, ); ctx.tools.register( "legal_style_guide", { displayName: "מדריך סגנון", description: "Get reference to Dafna's writing style guide.", parametersSchema: { type: "object", properties: {}, }, }, async () => { const guide = await api.getStyleGuide(); return { content: guide, data: { reference: guide } }; }, ); // ── Events ───────────────────────────────────────────────────── ctx.events.on("issue.created", async (event) => { // Auto-link issues with case number in title if (!event.companyId || !event.entityId) return; const issue = await ctx.issues.get(event.entityId, event.companyId); if (!issue) return; const match = issue.title.match(/ערר\s+(\d+\/\d+)/); if (match) { const caseNumber = match[1]; await ctx.state.set( { scopeKind: "issue", scopeId: issue.id, stateKey: "legal-case-number", }, caseNumber, ); ctx.logger.info("Auto-linked issue to legal case", { issueId: issue.id, caseNumber, }); } }); // Route user comments through CEO agent — per company ctx.events.on("issue.comment.created", async (event) => { // Only intercept human comments — not agent comments (prevents loops) if (event.actorType !== "user") return; if (!event.companyId) return; // entityId is the comment ID — fetch the comment to get issueId + body const entityId = event.entityId; if (!entityId) return; // The event payload may contain the issueId directly const payload = event.payload as { issueId?: string; body?: string; } | null; let issueId = payload?.issueId; let commentBody = payload?.body; // If issueId is not in payload, try to find it from the comment entity if (!issueId) { ctx.logger.warn( "issue.comment.created event missing issueId in payload, skipping", { entityId, payload }, ); return; } // Fetch issue details for context const issue = await ctx.issues.get(issueId, event.companyId); if (!issue) { ctx.logger.warn("Could not fetch issue for comment routing", { issueId, }); return; } // If comment body not in payload, fetch from API if (!commentBody) { try { const comments = await ctx.issues.listComments( issueId, event.companyId, ); const latest = comments[comments.length - 1]; commentBody = latest?.body || "(לא ניתן לקרוא את התגובה)"; } catch { commentBody = "(שגיאה בקריאת התגובה)"; } } // Wake the CEO agent for this company const ceoAgentId = CEO_AGENT_IDS[event.companyId]; if (!ceoAgentId) { ctx.logger.warn("No CEO agent mapped for company", { companyId: event.companyId, }); return; } try { const { runId } = await ctx.agents.invoke( ceoAgentId, event.companyId, { prompt: [ `תגובה חדשה מחיים על issue "${issue.title}" (${issue.identifier || issueId}):`, "", commentBody, "", `קרא את ה-comments האחרונים על ה-issue, הבן מה חיים מבקש, והחלט מה לעשות.`, `אם ההוראה ברורה — נתב לסוכן המתאים. אם לא ברור — שאל את חיים.`, ].join("\n"), reason: `user_commented_on_${issue.identifier || issueId}`, }, ); ctx.logger.info("Routed user comment to CEO agent", { issueId, commentId: entityId, runId, }); } catch (err) { ctx.logger.error("Failed to invoke CEO agent for comment routing", { issueId, error: String(err), }); } }); // ── Jobs ─────────────────────────────────────────────────────── ctx.jobs.register("sync-case-status", async (job) => { ctx.logger.info("Starting case status sync", { runId: job.runId }); try { const cases = await api.listCases(); const companies = await ctx.companies.list(); if (!companies.length) return; const companyId = companies[0].id; const issues = await ctx.issues.list({ companyId }); for (const legalCase of cases) { for (const issue of issues) { const linkedCase = await ctx.state.get({ scopeKind: "issue", scopeId: issue.id, stateKey: "legal-case-number", }); if (linkedCase === legalCase.case_number) { // Map 13 legal-ai statuses to Paperclip issue status const statusMap: Record = { new: "todo", uploading: "todo", processing: "in_progress", documents_ready: "in_progress", outcome_set: "in_progress", brainstorming: "in_progress", direction_approved: "in_progress", drafting: "in_progress", qa_review: "in_progress", drafted: "in_progress", exported: "in_progress", reviewed: "in_progress", final: "done", }; const statusLabels: Record = { new: "תיק חדש", uploading: "העלאת מסמכים", processing: "עיבוד מסמכים", documents_ready: "מסמכים מוכנים — הזן תוצאה", outcome_set: "תוצאה הוזנה — נדרש סיעור מוחות", brainstorming: "גיבוש כיוון בתהליך", direction_approved: "כיוון אושר — מוכן לכתיבה", drafting: "כתיבת החלטה בתהליך", qa_review: "בדיקת איכות", drafted: "טיוטה מוכנה — בדוק ושלח לדפנה", exported: "DOCX נוצר — ממתין לדפנה", reviewed: "דפנה הגיהה — העלה גרסה סופית", final: "גרסה סופית — לולאת למידה", }; const targetStatus = statusMap[legalCase.status]; const label = statusLabels[legalCase.status] || legalCase.status; if (targetStatus && issue.status !== targetStatus) { await ctx.issues.update( issue.id, { status: targetStatus }, companyId, ); await ctx.issues.createComment( issue.id, `📋 ${label}`, companyId, ); ctx.logger.info("Synced issue status", { issueId: issue.id, caseNumber: legalCase.case_number, newStatus: targetStatus, }); } } } } ctx.logger.info("Case status sync completed", { casesChecked: cases.length, }); } catch (err) { ctx.logger.error("Case status sync failed", { error: String(err) }); } }); ctx.jobs.register("stale-case-reminder", async (_job) => { ctx.logger.info("stale-case-reminder: starting"); const config = await ctx.config.get(); const apiBase = (config.legalApiBaseUrl as string) ?? "http://localhost:8085"; const resp = await ctx.http.fetch(`${apiBase}/api/cases/stale?days=3`); if (!resp.ok) { ctx.logger.error(`stale-case-reminder: API error ${resp.status}`); return; } const data = (await resp.json()) as { cases: Array<{ case_number: string; title: string; status: string; days_stale: number }>; total: number; }; // Build case→issue map once (O(companies × issues)) to avoid N×M RPCs per stale case const companies = await ctx.companies.list(); const caseIssueMap = new Map(); for (const company of companies) { const issues = await ctx.issues.list({ companyId: company.id }); for (const issue of issues) { const linkedCase = await ctx.state.get({ scopeKind: "issue", scopeId: issue.id, stateKey: "legal-case-number", }); if (linkedCase && typeof linkedCase === "string") { caseIssueMap.set(linkedCase, { issueId: issue.id, companyId: company.id }); } } } let reminded = 0; for (const staleCase of data.cases) { const linked = caseIssueMap.get(staleCase.case_number); if (!linked) continue; await ctx.issues.createComment( linked.issueId, `⚠️ **תיק תקוע ${staleCase.case_number}** — ${staleCase.days_stale} ימים ללא עדכון (סטטוס: ${staleCase.status}). האם נדרשת פעולה?`, linked.companyId, ); reminded++; ctx.logger.info(`stale-case-reminder: reminded case ${staleCase.case_number} (${staleCase.days_stale}d)`); } ctx.logger.info(`stale-case-reminder: done. ${reminded}/${data.total} cases reminded`); }); ctx.jobs.register("weekly-feedback-analysis", async (_job) => { ctx.logger.info("weekly-feedback-analysis: starting"); const config = await ctx.config.get(); const apiBase = (config.legalApiBaseUrl as string) ?? "http://localhost:8085"; const resp = await ctx.http.fetch(`${apiBase}/api/chair-feedback/weekly-summary`); if (!resp.ok) { ctx.logger.error(`weekly-feedback-analysis: API error ${resp.status}`); return; } const data = (await resp.json()) as { summary: string; entry_count: number }; if (data.entry_count === 0) { ctx.logger.info("weekly-feedback-analysis: no feedback this week, skipping"); return; } const companies = await ctx.companies.list(); const company = companies[0]; if (!company) return; const ceoId = CEO_AGENT_IDS[company.id]; if (!ceoId) { ctx.logger.warn(`weekly-feedback-analysis: no CEO agent for company ${company.id}`); return; } await ctx.agents.invoke(ceoId, company.id, { prompt: `ניתוח פידבק שבועי יו"ר (${data.entry_count} פריטים):\n\n${data.summary}\n\nהמשימה: עדכן את /home/chaim/legal-ai/docs/legal-decision-lessons.md עם הלקחים החדשים שעולים מהפידבק. הוסף רק לקחים חדשים שלא קיימים כבר. קבץ לפי נושא.`, reason: "weekly-feedback-analysis scheduled job", }); ctx.logger.info(`weekly-feedback-analysis: invoked CEO ${ceoId} with ${data.entry_count} feedback entries`); }); ctx.logger.info("Legal AI plugin ready"); }, async onHealth() { return { status: "ok" as const }; }, async onWebhook(input: PluginWebhookInput): Promise { if (!pluginCtx) return; // not yet initialized // TODO: add idempotency guard using input.requestId to prevent duplicate // comments on rapid retries (store requestId in plugin state with ~60s TTL) const { endpointKey, parsedBody } = input; if (endpointKey !== "case-status") return; const payload = parsedBody as { caseNumber: string; oldStatus: string; newStatus: string; companyId: string | null; timestamp: string; }; const { caseNumber, oldStatus, newStatus, companyId } = payload; if (!caseNumber || !newStatus || !companyId) { pluginCtx.logger.warn("onWebhook: malformed payload", { caseNumber, newStatus, companyId }); return; } pluginCtx.logger.info(`Webhook: case ${caseNumber} ${oldStatus} → ${newStatus}`, { companyId, }); // Find the Paperclip issue linked to this case number by scanning plugin state. // State stores: issue.id → case_number (scopeKind=issue, stateKey=legal-case-number) const issues = await pluginCtx.issues.list({ companyId }); let linkedIssueId: string | null = null; for (const issue of issues) { const linkedCase = await pluginCtx.state.get({ scopeKind: "issue", scopeId: issue.id, stateKey: "legal-case-number", }); if (linkedCase === caseNumber) { linkedIssueId = issue.id; break; } } if (!linkedIssueId) { pluginCtx.logger.warn(`onWebhook: no Paperclip issue linked to case ${caseNumber}`); return; } // Status label map (Hebrew) const statusLabels: Record = { new: "📂 תיק חדש", uploading: "📤 העלאת מסמכים", processing: "⚙️ עיבוד מסמכים", documents_ready: "📁 מסמכים מוכנים", outcome_set: "🎯 תוצאה הוזנה", brainstorming: "💡 סיעור מוחות", direction_approved: "✅ כיוון אושר", drafting: "✍️ כתיבה בתהליך", qa_review: "🔍 בדיקת איכות", in_progress: "🔄 בעבודה", drafted: "✍️ טיוטה מוכנה", qa_failed: "❌ QA נכשל", exported: "📄 יוצא ל-DOCX", reviewed: "✅ נבדק", final: "🎯 סופי", }; const label = statusLabels[newStatus] ?? newStatus; // Post a Hebrew status comment on the linked issue await pluginCtx.issues.createComment( linkedIssueId, `**עדכון סטטוס תיק ${caseNumber}:** ${label} (היה: ${oldStatus})`, companyId, ); // Wake the CEO agent if QA failed if (newStatus === "qa_failed") { const ceoId = CEO_AGENT_IDS[companyId]; if (ceoId) { try { const { runId } = await pluginCtx.agents.invoke(ceoId, companyId, { prompt: `תיק ${caseNumber} נכשל בבדיקת QA. עיין בתוצאות QA ותקן את הבעיות לפני שתמשיך.`, reason: "qa_failed webhook", }); pluginCtx.logger.info(`Invoked CEO agent for qa_failed`, { caseNumber, ceoId, runId, }); } catch (err) { pluginCtx.logger.error("Failed to invoke CEO agent for qa_failed", { caseNumber, error: String(err), }); } } else { pluginCtx.logger.warn("onWebhook: no CEO agent mapped for company", { companyId, }); } } }, }); export default plugin; runWorker(plugin, import.meta.url);