import type { PluginContext, PluginWebhookInput, } from "@paperclipai/plugin-sdk"; import { definePlugin, runWorker } 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 DEFAULT_LEGAL_API_BASE = "http://localhost:8085"; /** * Resolve `legalApiBaseUrl` from company-scoped plugin config. * * ⚠️ Plugin config became company-scoped in @paperclipai/server 2026.722.0 * (migration `0164_plugin_config_company_scope`). A bare `ctx.config.get()` now * throws `company context is required` unless the host can derive the company * from the current invocation — which it CANNOT during `setup()`, because * worker initialisation belongs to no company. Reading config at startup is what * made the whole plugin fail to activate on the 722.0 upgrade. * * So: never call this from setup(); call it from a handler, passing the * companyId that handler was given. Tool handlers get `runCtx.companyId`; data * handlers get `params.companyId`. Scheduled jobs have no company at all, so * they fall back to probing the companies we know about. */ /** Pull the companyId the host passes to `ctx.data` handlers, if present. */ function companyIdOf(params: unknown): string | undefined { const id = (params as { companyId?: unknown } | null)?.companyId; return typeof id === "string" && id.trim() ? id : undefined; } async function resolveApiBase( ctx: PluginContext, companyId?: string, ): Promise { const read = async (id?: string): Promise => { try { const cfg = (await ctx.config.get(id)) as { legalApiBaseUrl?: unknown; } | null; const url = cfg?.legalApiBaseUrl; return typeof url === "string" && url.trim() ? url.trim() : null; } catch { return null; } }; if (companyId) { const scoped = await read(companyId); if (scoped) return scoped; } // No companyId passed in — but the host derives one itself when the call // happens inside a host-scoped invocation (e.g. a tool handler). Ask before // falling back to guesswork, so tools honour their own company's config. const derived = await read(undefined); if (derived) return derived; // Genuinely no company (scheduled jobs): try each company we know of. All of // them point at the same legal-ai instance in practice, so the first hit wins. for (const knownCompanyId of Object.keys(CEO_AGENT_IDS)) { const url = await read(knownCompanyId); if (url) return url; } ctx.logger.warn("legalApiBaseUrl unresolved — using default", { companyId: companyId ?? null, fallback: DEFAULT_LEGAL_API_BASE, }); return DEFAULT_LEGAL_API_BASE; } const plugin = definePlugin({ async setup(ctx) { pluginCtx = ctx; // save for onWebhook // Lazy: the URL is fetched on the first request, from inside a handler // that has company context — not here. See resolveApiBase() above. const api = new LegalApi(() => resolveApiBase(ctx)); ctx.logger.info("Legal AI plugin starting"); // ── 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 } }; }, ); ctx.tools.register( "legal_predecessor_context", { displayName: "הקשר מריצות קודמות", description: "Recent conclusions from prior heartbeat runs on this case (the 'summary' each run left). Call this when RESUMING work on a case to see what earlier sessions already did/decided — instead of re-deriving context from scratch. Provide the case number.", parametersSchema: { type: "object", properties: { case_number: { type: "string", description: "Case number (e.g. 1043-02-26)", }, }, required: ["case_number"], }, }, async (params) => { const { case_number } = params as { case_number: string }; const result = await api.getPredecessorForCase(case_number); // Readable digest for the agent — each prior run's conclusion, // newest-first, summaries trimmed to keep the wake context lean. const lines = result.runs.map((r) => { const summary = (r.summary ?? "").trim().slice(0, 600); const when = r.finished_at ?? r.started_at ?? ""; return `▸ ${r.agent_name ?? "?"} · ${r.identifier ?? ""} · ${when} · ${r.status}\n${summary}`; }); const content = lines.length ? lines.join("\n\n") : "אין ריצות קודמות עם מסקנות לתיק זה."; return { content, data: result }; }, ); // ── 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 // Marker: the id of the last user comment already routed to the CEO for // an issue. BOTH the event-driven route below and the reconciliation // sweep (`route-pending-comments`) set it, so the sweep never re-routes a // comment the fast path already handled. This is what turns best-effort // event delivery into an at-least-once guarantee. const LAST_ROUTED_COMMENT_KEY = "last-routed-comment-id"; async function markCommentRouted( issueId: string, commentId: string | undefined, ): Promise { if (!commentId) return; try { await ctx.state.set( { scopeKind: "issue", scopeId: issueId, stateKey: LAST_ROUTED_COMMENT_KEY, }, commentId, ); } catch (err) { ctx.logger.warn("Failed to persist last-routed-comment marker", { issueId, commentId, error: String(err), }); } } // Invoke the company CEO to handle a user comment on an issue. Shared by // the event-driven route and the reconciliation sweep. On success it // stamps the marker so the comment is not routed twice. Returns true when // the CEO was invoked. async function routeCommentToCeo(args: { issue: { id: string; title: string; identifier?: string | null }; companyId: string; ceoAgentId: string; commentBody: string; commentId?: string; source: "event" | "sweep"; }): Promise { const { issue, companyId, ceoAgentId, commentBody, commentId, source } = args; const promptBody = commentBody ? commentBody : "(לא ניתן לקרוא את תוכן התגובה — קרא את ה-comments האחרונים על ה-issue ישירות)"; try { const { runId } = await ctx.agents.invoke(ceoAgentId, companyId, { prompt: [ `תגובה חדשה מחיים על issue "${issue.title}" (${issue.identifier || issue.id}):`, "", promptBody, "", `קרא את ה-comments האחרונים על ה-issue, הבן מה חיים מבקש, והחלט מה לעשות.`, `אם ההוראה ברורה — נתב לסוכן המתאים. אם לא ברור — שאל את חיים.`, ].join("\n"), reason: `user_commented_on_${issue.identifier || issue.id}`, }); await markCommentRouted(issue.id, commentId); ctx.logger.info("Routed user comment to CEO agent", { issueId: issue.id, commentId: commentId || null, runId, source, }); return true; } catch (err) { ctx.logger.error("Failed to invoke CEO agent for comment routing", { issueId: issue.id, source, error: String(err), }); return false; } } 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; // Event/payload shape for `issue.comment.created` (Paperclip host): // event.entityId → the ISSUE id (the primary entity of the event) // event.payload.commentId → the comment id // event.payload.bodySnippet → TRUNCATED body (not the full text) // event.payload.reopened / reopenedFrom → set when the comment // reopened a `done` issue (host then natively wakes its assignee) // NOTE: the host does NOT send `payload.issueId` and does NOT send the // full `body`. The earlier code read `payload.issueId` (always absent) // and skipped every event — silently disabling comment→CEO routing. // See @paperclipai/plugin-sdk index.d.ts: `issueId: event.entityId`. const payload = (event.payload ?? null) as { issueId?: string; commentId?: string; body?: string; bodySnippet?: string; reopened?: boolean; reopenedFrom?: string; } | null; // issueId: prefer entityId (current host), fall back to payload for // forward/backward compatibility with other host versions. const issueId = event.entityId || payload?.issueId; if (!issueId) { ctx.logger.warn( "issue.comment.created event missing issueId (entityId+payload empty), skipping", { entityId: event.entityId, payload }, ); return; } const commentId = payload?.commentId; // 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; } // 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; } // Dedup against the host's native reopen-on-comment wake: when a comment // reopens a `done` issue, the host already wakes that issue's assignee. // If the assignee IS this company's CEO, that native wake covers us — // invoking again would double-run the CEO. Skip ONLY in that exact case. // For issues owned by any other agent (e.g. an analyst sub-task), the // native wake targets the wrong agent (and the queued run is cancelled // with `issue_assignee_changed`), so we MUST route the comment to the CEO. if (issue.assigneeAgentId === ceoAgentId && payload?.reopened === true) { ctx.logger.info( "Comment reopened CEO-owned issue; native wake handles it, skipping plugin route", { issueId, ceoAgentId }, ); // The comment IS being handled (natively) — stamp the marker so the // reconciliation sweep does not re-route it a second time. await markCommentRouted(issueId, commentId); return; } // Resolve the full comment body. The payload only carries a truncated // snippet, so fetch the comment list and match by id (fall back to the // latest comment, then the snippet). let commentBody = payload?.body; if (!commentBody) { try { const comments = await ctx.issues.listComments( issueId, event.companyId, ); const matched = commentId ? comments.find((c) => c.id === commentId) : undefined; // Newest by createdAt — do NOT rely on listComments array order. const latest = comments.reduce<(typeof comments)[number] | null>( (acc, c) => !acc || new Date(c.createdAt).getTime() > new Date(acc.createdAt).getTime() ? c : acc, null, ); commentBody = matched?.body || latest?.body || payload?.bodySnippet || "(לא ניתן לקרוא את התגובה)"; } catch (err) { ctx.logger.warn("listComments failed, falling back to bodySnippet", { issueId, commentId, error: String(err), }); commentBody = payload?.bodySnippet ?? ""; } } // Route through the shared helper (also used by the sweep). It stamps // the `last-routed-comment-id` marker on success so the reconciliation // sweep does not re-deliver this comment. await routeCommentToCeo({ issue, companyId: event.companyId, ceoAgentId, commentBody: commentBody ?? "", commentId, source: "event", }); }); // ── 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"); // Scheduled job — no company context; resolveApiBase probes known companies. const apiBase = await resolveApiBase(ctx); let resp: Awaited>; try { resp = await ctx.http.fetch(`${apiBase}/api/cases/stale?days=30`); } catch (err) { ctx.logger.error("stale-case-reminder: fetch failed", { error: String(err), }); return; } 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< string, { issueId: string; companyId: string } >(); 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"); // Scheduled job — no company context; resolveApiBase probes known companies. const apiBase = await resolveApiBase(ctx); 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; } // Pick the first company with a known CEO mapping — decision-lessons.md is // shared between companies, so a single CEO invocation is sufficient. const companies = await ctx.companies.list(); const mapped = companies .map((c) => ({ company: c, ceoId: CEO_AGENT_IDS[c.id] })) .filter((x): x is { company: (typeof companies)[0]; ceoId: string } => Boolean(x.ceoId), ); if (mapped.length === 0) { ctx.logger.warn( "weekly-feedback-analysis: no company has a mapped CEO agent — skipping", ); return; } const { company, ceoId } = mapped[0]; try { 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-job", }); ctx.logger.info( `weekly-feedback-analysis: invoked CEO ${ceoId} (company ${company.id}) with ${data.entry_count} feedback entries`, ); } catch (err) { ctx.logger.error("weekly-feedback-analysis: failed to invoke CEO", { error: String(err), ceoId, companyId: company.id, }); } }); // Reconciliation sweep — the at-least-once guarantee for user comments. // The event-driven route above is best-effort: a failed/coalesced CEO // invoke, or a comment that lands while an agent is mid-run, can leave a // user comment unrouted with nothing to retry (this is exactly how a // chair comment was silently dropped — see legal-ai task #164). This sweep // re-routes any issue whose newest user comment id does not match the // `last-routed-comment-id` marker, so no comment falls through. Idempotent: // the fast path stamps the marker, so a healthy comment is never // double-routed. Runs every 2 minutes (manifest schedule). ctx.jobs.register("route-pending-comments", async (_job) => { let companies: Awaited>; try { companies = await ctx.companies.list(); } catch (err) { ctx.logger.warn("route-pending-comments: companies.list failed", { error: String(err), }); return; } for (const company of companies) { const ceoAgentId = CEO_AGENT_IDS[company.id]; if (!ceoAgentId) continue; let issues: Awaited>; try { issues = await ctx.issues.list({ companyId: company.id, limit: 200 }); } catch (err) { ctx.logger.warn("route-pending-comments: issues.list failed", { companyId: company.id, error: String(err), }); continue; } for (const issue of issues) { // Terminal issues need no routing. A fresh user comment on a // `done` issue reopens it natively → it becomes active and is // caught on the next sweep. if (issue.status === "done" || issue.status === "cancelled") continue; try { const comments = await ctx.issues.listComments( issue.id, company.id, ); if (comments.length === 0) continue; // "Pending" = the chair spoke last among real participants: the // newest USER comment is newer than the newest AGENT comment (or // no agent has commented yet), so an agent never responded to it. // Order-independent (sort by createdAt, do NOT rely on the array // order of listComments) and system comments are ignored — an // automated "draft ready" line is not an agent response. This // targets the exact failure (chair commented, no agent reply) and // skips historically-answered comments. const ts = (c: { createdAt: string | Date }) => new Date(c.createdAt).getTime(); let newestUser: (typeof comments)[number] | null = null; let newestAgent: (typeof comments)[number] | null = null; for (const c of comments) { if (c.authorType === "user") { if (!newestUser || ts(c) > ts(newestUser)) newestUser = c; } else if (c.authorType === "agent") { if (!newestAgent || ts(c) > ts(newestAgent)) newestAgent = c; } } if (!newestUser) continue; // no chair comment // An agent has already responded after the chair — handled. if (newestAgent && ts(newestAgent) >= ts(newestUser)) continue; const marker = await ctx.state.get({ scopeKind: "issue", scopeId: issue.id, stateKey: LAST_ROUTED_COMMENT_KEY, }); // Already routed (CEO may still be working) — don't re-fire. if (marker === newestUser.id) continue; ctx.logger.info( "route-pending-comments: re-routing unrouted user comment", { issueId: issue.id, commentId: newestUser.id }, ); await routeCommentToCeo({ issue, companyId: company.id, ceoAgentId, commentBody: newestUser.body, commentId: newestUser.id, source: "sweep", }); } catch (err) { ctx.logger.warn("route-pending-comments: failed for issue", { issueId: issue.id, error: String(err), }); } } } }); // ── Data handlers (UI bridge) ────────────────────────────────── // These back `usePluginData(key, params)` calls from the React UI bundle // in `dist/ui/index.js`. Errors are caught and rethrown with a clear // message so the UI shows them through PluginBridgeError. // Resolve the legal-ai case number linked to an issue via plugin state. async function resolveCaseNumber(issueId: string): Promise { const value = await ctx.state.get({ scopeKind: "issue", scopeId: issueId, stateKey: "legal-case-number", }); return typeof value === "string" && value.length > 0 ? value : null; } ctx.data.register("legal-case-summary", async (params) => { const issueId = String((params as { issueId?: string }).issueId ?? ""); if (!issueId) return null; const caseNumber = await resolveCaseNumber(issueId); if (!caseNumber) return null; try { return await api.getCase(caseNumber); } catch (err) { ctx.logger.warn("legal-case-summary: getCase failed", { caseNumber, error: String(err), }); throw err; } }); ctx.data.register("legal-case-arguments", async (params) => { const issueId = String((params as { issueId?: string }).issueId ?? ""); if (!issueId) return null; const caseNumber = await resolveCaseNumber(issueId); if (!caseNumber) return null; const apiBase = await resolveApiBase(ctx, companyIdOf(params)); try { const resp = await ctx.http.fetch( `${apiBase}/api/cases/${encodeURIComponent(caseNumber)}/legal-arguments`, ); if (!resp.ok) { ctx.logger.warn("legal-case-arguments: API error", { caseNumber, status: resp.status, }); return { case_number: caseNumber, total: 0, by_party: {}, arguments: [], }; } return await resp.json(); } catch (err) { ctx.logger.warn("legal-case-arguments: fetch failed", { caseNumber, error: String(err), }); return { case_number: caseNumber, total: 0, by_party: {}, arguments: [], }; } }); ctx.data.register("legal-case-precedents", async (params) => { const issueId = String((params as { issueId?: string }).issueId ?? ""); if (!issueId) return []; const caseNumber = await resolveCaseNumber(issueId); if (!caseNumber) return []; const apiBase = await resolveApiBase(ctx, companyIdOf(params)); try { const resp = await ctx.http.fetch( `${apiBase}/api/cases/${encodeURIComponent(caseNumber)}/precedents`, ); if (!resp.ok) { ctx.logger.warn("legal-case-precedents: API error", { caseNumber, status: resp.status, }); return []; } const data = await resp.json(); // Endpoint may return a flat array or `{ precedents: [...] }`. if (Array.isArray(data)) return data; if ( data && Array.isArray((data as { precedents?: unknown[] }).precedents) ) { return (data as { precedents: unknown[] }).precedents; } return []; } catch (err) { ctx.logger.warn("legal-case-precedents: fetch failed", { caseNumber, error: String(err), }); return []; } }); ctx.data.register("legal-case-missing-precedents", async (params) => { const issueId = String((params as { issueId?: string }).issueId ?? ""); if (!issueId) return []; const caseNumber = await resolveCaseNumber(issueId); if (!caseNumber) return []; const apiBase = await resolveApiBase(ctx, companyIdOf(params)); try { const url = new URL(`${apiBase}/api/missing-precedents`); url.searchParams.set("case_number", caseNumber); url.searchParams.set("status", "open"); const resp = await ctx.http.fetch(url.toString()); if (!resp.ok) { ctx.logger.warn("legal-case-missing-precedents: API error", { caseNumber, status: resp.status, }); return []; } const data = (await resp.json()) as { items?: unknown[] }; return Array.isArray(data.items) ? data.items : []; } catch (err) { ctx.logger.warn("legal-case-missing-precedents: fetch failed", { caseNumber, error: String(err), }); return []; } }); ctx.data.register("legal-dashboard-stats", async () => { try { const cases = await api.listCases(); const byStatus: Record = {}; let weekActivity = 0; const weekAgoMs = Date.now() - 7 * 24 * 60 * 60 * 1000; for (const c of cases) { byStatus[c.status] = (byStatus[c.status] ?? 0) + 1; const updated = (c as { updated_at?: string | null }).updated_at; if (updated) { const t = Date.parse(updated); if (!Number.isNaN(t) && t >= weekAgoMs) weekActivity += 1; } } return { byStatus, weekActivity, totalCases: cases.length, }; } catch (err) { ctx.logger.warn("legal-dashboard-stats: listCases failed", { error: String(err), }); return { byStatus: {}, weekActivity: 0, totalCases: 0 }; } }); 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 // Idempotency guard: skip duplicate deliveries within 5 minutes if (input.requestId) { const idempKey = `webhook-idem-${input.requestId}`; const seenAt = await pluginCtx.state.get({ scopeKind: "instance", stateKey: idempKey, }); if (seenAt && typeof seenAt === "string") { const ageMs = Date.now() - new Date(seenAt).getTime(); if (ageMs < 5 * 60 * 1000) { pluginCtx.logger.info( `onWebhook: skipping duplicate requestId ${input.requestId} (age ${Math.round(ageMs / 1000)}s)`, ); return; } } await pluginCtx.state.set( { scopeKind: "instance", stateKey: idempKey }, new Date().toISOString(), ); } const { endpointKey, parsedBody } = input; if (endpointKey !== "case-status") return; // Webhook payload is a discriminated union by `eventType`: // • undefined / "status_change" — legacy case-status update (default) // • "missing_precedent_created" — ask Daphna to upload a missing citation // • "export_complete" — attach a generated DOCX to the linked issue const payload = parsedBody as { eventType?: string; caseNumber: string; companyId: string | null; timestamp: string; // status_change fields oldStatus?: string; newStatus?: string; // missing_precedent_created fields missingPrecedent?: { id: string; citation: string; citedByParty?: string; citedByPartyName?: string; legalTopic?: string; legalIssue?: string; }; // export_complete fields docxBase64?: string; docxFilename?: string; docxTitle?: string; }; const { caseNumber, companyId } = payload; const eventType = payload.eventType ?? "status_change"; if (!caseNumber || !companyId) { pluginCtx.logger.warn("onWebhook: malformed payload", { eventType, caseNumber, companyId, }); return; } pluginCtx.logger.info( `Webhook: case ${caseNumber} eventType=${eventType}`, { 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; } // ── Branch by eventType ──────────────────────────────────────── // EVENT: missing_precedent_created — ask Daphna to upload via SDK // interaction (ask_user_questions). Avoids plain-text comment for // a decision that has a clear set of choices. if (eventType === "missing_precedent_created" && payload.missingPrecedent) { const mp = payload.missingPrecedent; const partyLabel = mp.citedByPartyName || mp.citedByParty || "אחד הצדדים"; try { await pluginCtx.issues.askUserQuestions( linkedIssueId, { continuationPolicy: "wake_assignee_on_accept", payload: { version: 1, title: `פסיקה חסרה בקורפוס: ${mp.citation}`, questions: [ { id: `missing_precedent:${mp.id}`, prompt: `יש להעלות את ${mp.citation}`, helpText: [ `הציטוט הוזכר על-ידי ${partyLabel} בתיק ${caseNumber}.`, mp.legalTopic ? `נושא: ${mp.legalTopic}` : "", mp.legalIssue ? `סוגיה: ${mp.legalIssue}` : "", "", "כדי שהמערכת תוכל לבחון את הטענה מול הפסיקה — יש להעלות את ה-PDF/DOCX לדף /missing-precedents.", ] .filter(Boolean) .join("\n"), selectionMode: "single", required: true, options: [ { id: "upload", label: "אני מעלה PDF/DOCX" }, { id: "irrelevant", label: "ההלכה לא רלוונטית" }, { id: "defer", label: "אכריע מאוחר יותר" }, ], }, ], }, }, companyId, ); pluginCtx.logger.info( "askUserQuestions: missing_precedent prompt sent", { caseNumber, missingPrecedentId: mp.id, }, ); } catch (err) { pluginCtx.logger.error( "askUserQuestions failed for missing_precedent", { caseNumber, missingPrecedentId: mp.id, error: String(err), }, ); } return; } // EVENT: export_complete — attach a markdown "final decision" // document to the issue, with a link back to the DOCX in legal-ai. // The SDK's `documents.upsert` stores text (markdown), so we keep // the binary DOCX in legal-ai and reference it; the issue page // shows the document as a discoverable artifact. if (eventType === "export_complete" && payload.docxFilename) { const filename = payload.docxFilename; const title = payload.docxTitle || `החלטה סופית — ${caseNumber}`; const docxUrl = `https://legal-ai.nautilus.marcusgroup.org/api/cases/${encodeURIComponent(caseNumber)}/export/download?filename=${encodeURIComponent(filename)}`; const markdownBody = [ `# ${title}`, "", `**תיק:** ${caseNumber}`, `**קובץ:** \`${filename}\``, `**הופק:** ${payload.timestamp}`, "", `[הורדה](${docxUrl})`, "", "---", "", 'החלטה זו יוצאה אוטומטית ע"י legal-ai והוצמדה ל-issue זה.', ].join("\n"); try { await pluginCtx.issues.documents.upsert({ issueId: linkedIssueId, companyId, key: `final-decision:${caseNumber}`, body: markdownBody, title, format: "markdown", changeSummary: `Auto-attached final DOCX (${filename})`, }); pluginCtx.logger.info("documents.upsert: final-decision attached", { caseNumber, filename, }); } catch (err) { pluginCtx.logger.error("documents.upsert failed for export_complete", { caseNumber, filename, error: String(err), }); } return; } // EVENT: status_change (default) — legacy behavior. const { oldStatus = "", newStatus = "" } = payload; if (!newStatus) { pluginCtx.logger.warn("onWebhook status_change: missing newStatus", { 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);