מיגרציה 0164_plugin_config_company_scope הפכה את config של פלאגינים ל-company-scoped, ו-`ctx.config.get()` בלי companyId זורק "company context is required" כשה-host לא יכול לגזור חברה מההקשר. ב-setup() אין חברה בהגדרה, ולכן הקריאה שם הפילה את הפעלת הפלאגין כולו בשדרוג מ-2026.609.0 (status=error, 0 כלים רשומים). - setup() כבר לא קורא config; LegalApi מקבל resolver עצל במקום URL קבוע (baseUrl שימש במקום אחד בלבד, ולכן 17 ה-tool handlers לא נגעו) - resolveApiBase() פותר לפי הסדר: companyId מפורש → גזירת ה-host מההקשר → סריקת החברות ב-CEO_AGENT_IDS → ברירת מחדל - data handlers מעבירים params.companyId; jobs מתוזמנים חסרי חברה (PluginJobContext לא כולל companyId) ולכן נשענים על הסריקה - שדרוג @paperclipai/plugin-sdk ל-2026.722.0 מאומת: הפלאגין עולה עם אותה חתימת רישום כמו לפני השדרוג — eventSubscriptions:2, jobs:4, webhooks:1, tools:9, בלי warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1597 lines
50 KiB
TypeScript
1597 lines
50 KiB
TypeScript
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<string, string> = {
|
||
"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<string> {
|
||
const read = async (id?: string): Promise<string | null> => {
|
||
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<void> {
|
||
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<boolean> {
|
||
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<string, "todo" | "in_progress" | "done"> =
|
||
{
|
||
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<string, string> = {
|
||
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<ReturnType<typeof ctx.http.fetch>>;
|
||
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<ReturnType<typeof ctx.companies.list>>;
|
||
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<ReturnType<typeof ctx.issues.list>>;
|
||
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<string | null> {
|
||
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<string, number> = {};
|
||
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<void> {
|
||
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<string, string> = {
|
||
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);
|