Files
plugin-legal-ai/src/worker.ts
Chaim Marcus 6294d94bed fix(config): קריאת config מותאמת ל-scope של חברה (Paperclip 2026.722.0)
מיגרציה 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>
2026-07-25 16:18:30 +00:00

1597 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: `תיק ערר חדש\ושא: ${input.subject || ""}\וצאה צפויה: ${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);