11 Commits

Author SHA1 Message Date
73e37df129 fix: add try-catch on agents.invoke and http.fetch in scheduled jobs
- stale-case-reminder: wrap http.fetch in try-catch so network errors
  log and return instead of crashing the job silently
- weekly-feedback-analysis: wrap agents.invoke in try-catch so CEO
  unavailability logs and returns instead of crashing the job

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 11:07:54 +00:00
6a93c606b9 fix: pick first mapped CEO for weekly-feedback-analysis; align reason string
- Replace companies[0] with first company that has a CEO_AGENT_IDS entry,
  so the job doesn't silently skip if the list order is non-deterministic
- Change reason to "weekly-feedback-job" to match the CEO routing condition
  added in legal-ceo.md (was "weekly-feedback-analysis scheduled job")
- decision-lessons.md is shared between companies so one CEO invocation suffices

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 10:54:39 +00:00
73078946f1 feat: add idempotency guard to onWebhook
Prevent duplicate comments on rapid Paperclip retries:
- Check plugin instance state for requestId before processing
- Skip if same requestId was seen within 5 minutes
- Store requestId with ISO timestamp after first successful delivery

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 10:52:12 +00:00
52c3e600e7 style: biome auto-fix (const, import order, formatter)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 10:18:33 +00:00
98c87a5d70 perf: build case-issue map once in stale-case-reminder; add idempotency TODO
- Hoist issues.list() loop outside stale-cases loop — reduces RPCs from
  O(cases × companies × issues) to O(companies × issues)
- Add TODO comment for requestId-based idempotency guard in onWebhook

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 10:15:51 +00:00
09acb021eb perf: hoist companies.list() outside stale-case loop 2026-05-16 17:41:43 +00:00
06679bb061 feat: add stale-case-reminder and weekly-feedback-analysis jobs
Declare two new cron jobs in plugin.json and manifest.ts, and implement
their handlers in worker.ts. stale-case-reminder runs daily at 08:00 and
posts a warning comment on any Paperclip issue linked to a legal-ai case
that has not been updated in 3+ days. weekly-feedback-analysis runs every
Sunday at 19:00, fetches the weekly chair-feedback summary from legal-ai,
and invokes the CEO agent to update decision-lessons.md with new lessons.
2026-05-16 17:40:33 +00:00
9633617e26 fix: declare case-status webhook endpoint in manifest
Add webhooks[] array to plugin.json and manifest.ts with the
'case-status' endpointKey. Without this declaration, Paperclip
registers 0 webhooks even when onWebhook() is implemented, so
POST /api/plugins/marcusgroup.legal-ai/webhooks/case-status would
return 501 and never reach the handler.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 17:29:56 +00:00
f5a4cd1c62 feat: add webhooks.receive capability to plugin manifest 2026-05-16 17:19:10 +00:00
23fda8da8c fix: add payload validation guard in onWebhook handler 2026-05-16 17:18:26 +00:00
6b856dbe85 feat: add onWebhook handler for case-status events
Adds the onWebhook lifecycle hook to the definePlugin() call. When
legal-ai POSTs to /webhooks/case-status, the handler finds the linked
Paperclip issue (via plugin state scan), posts a Hebrew status comment,
and wakes the CEO agent on qa_failed. Hoists PluginContext and
CEO_AGENT_IDS to module scope so onWebhook can access them after setup().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 17:16:28 +00:00
3 changed files with 349 additions and 20 deletions

View File

@@ -23,7 +23,8 @@
"jobs.schedule",
"activity.log.write",
"companies.read",
"projects.read"
"projects.read",
"webhooks.receive"
],
"entrypoints": {
"worker": "dist/worker.js"
@@ -62,6 +63,25 @@
"displayName": "סנכרון סטטוס תיקים",
"description": "סנכרון סטטוס בין legal-ai ל-Paperclip כל 15 דקות",
"schedule": "*/15 * * * *"
},
{
"jobKey": "stale-case-reminder",
"displayName": "תזכורת תיקים תקועים",
"description": "מזהה תיקים שלא עודכנו 3+ ימים ומוסיף תגובה ל-issue",
"schedule": "0 8 * * *"
},
{
"jobKey": "weekly-feedback-analysis",
"displayName": "ניתוח פידבק שבועי",
"description": "מסכם פידבק יו\"ר מהשבוע האחרון ומעדכן את decision-lessons.md",
"schedule": "0 19 * * 0"
}
],
"webhooks": [
{
"endpointKey": "case-status",
"displayName": "עדכון סטטוס תיק",
"description": "מקבל עדכוני סטטוס מ-legal-ai ומפרסם תגובה על ה-issue המקושר"
}
]
}

View File

@@ -21,6 +21,7 @@ export default {
"activity.log.write",
"companies.read",
"projects.read",
"webhooks.receive",
] as const,
entrypoints: {
worker: "dist/worker.js",
@@ -157,5 +158,26 @@ export default {
"Polls legal-ai for case status changes and updates Paperclip issues",
schedule: "*/15 * * * *",
},
{
jobKey: "stale-case-reminder",
displayName: "תזכורת תיקים תקועים",
description: "מזהה תיקים שלא עודכנו 3+ ימים ומוסיף תגובה ל-issue",
schedule: "0 8 * * *",
},
{
jobKey: "weekly-feedback-analysis",
displayName: "ניתוח פידבק שבועי",
description:
'מסכם פידבק יו"ר מהשבוע האחרון ומעדכן את decision-lessons.md',
schedule: "0 19 * * 0",
},
],
webhooks: [
{
endpointKey: "case-status",
displayName: "עדכון סטטוס תיק",
description:
"מקבל עדכוני סטטוס מ-legal-ai ומפרסם תגובה על ה-issue המקושר",
},
],
};

View File

@@ -1,8 +1,24 @@
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 plugin = definePlugin({
async setup(ctx) {
pluginCtx = ctx; // save for onWebhook
const config = (await ctx.config.get()) as {
legalApiBaseUrl?: string;
} | null;
@@ -517,8 +533,7 @@ const plugin = definePlugin({
}
});
// Route user comments through CEO agent
const CEO_AGENT_ID = "752cebdd-6748-4a04-aacd-c7ab0294ef33";
// Route user comments through CEO agent — per company
ctx.events.on("issue.comment.created", async (event) => {
// Only intercept human comments — not agent comments (prevents loops)
@@ -535,7 +550,7 @@ const plugin = definePlugin({
body?: string;
} | null;
let issueId = payload?.issueId;
const issueId = payload?.issueId;
let commentBody = payload?.body;
// If issueId is not in payload, try to find it from the comment entity
@@ -570,23 +585,27 @@ const plugin = definePlugin({
}
}
// Wake the CEO agent with the comment context
// Wake the CEO agent for this company
const ceoAgentId = CEO_AGENT_IDS[event.companyId];
if (!ceoAgentId) {
ctx.logger.warn("No CEO agent mapped for company", {
companyId: event.companyId,
});
return;
}
try {
const { runId } = await ctx.agents.invoke(
CEO_AGENT_ID,
event.companyId,
{
prompt: [
`תגובה חדשה מחיים על issue "${issue.title}" (${issue.identifier || issueId}):`,
"",
commentBody,
"",
`קרא את ה-comments האחרונים על ה-issue, הבן מה חיים מבקש, והחלט מה לעשות.`,
`אם ההוראה ברורה — נתב לסוכן המתאים. אם לא ברור — שאל את חיים.`,
].join("\n"),
reason: `user_commented_on_${issue.identifier || issueId}`,
},
);
const { runId } = await ctx.agents.invoke(ceoAgentId, event.companyId, {
prompt: [
`תגובה חדשה מחיים על issue "${issue.title}" (${issue.identifier || issueId}):`,
"",
commentBody,
"",
`קרא את ה-comments האחרונים על ה-issue, הבן מה חיים מבקש, והחלט מה לעשות.`,
`אם ההוראה ברורה — נתב לסוכן המתאים. אם לא ברור — שאל את חיים.`,
].join("\n"),
reason: `user_commented_on_${issue.identifier || issueId}`,
});
ctx.logger.info("Routed user comment to CEO agent", {
issueId,
commentId: entityId,
@@ -688,12 +707,280 @@ const plugin = definePlugin({
}
});
ctx.jobs.register("stale-case-reminder", async (_job) => {
ctx.logger.info("stale-case-reminder: starting");
const config = await ctx.config.get();
const apiBase =
(config.legalApiBaseUrl as string) ?? "http://localhost:8085";
let resp: Awaited<ReturnType<typeof ctx.http.fetch>>;
try {
resp = await ctx.http.fetch(`${apiBase}/api/cases/stale?days=3`);
} 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");
const config = await ctx.config.get();
const apiBase =
(config.legalApiBaseUrl as string) ?? "http://localhost:8085";
const resp = await ctx.http.fetch(
`${apiBase}/api/chair-feedback/weekly-summary`,
);
if (!resp.ok) {
ctx.logger.error(`weekly-feedback-analysis: API error ${resp.status}`);
return;
}
const data = (await resp.json()) as {
summary: string;
entry_count: number;
};
if (data.entry_count === 0) {
ctx.logger.info(
"weekly-feedback-analysis: no feedback this week, skipping",
);
return;
}
// 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,
});
}
});
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;
const payload = parsedBody as {
caseNumber: string;
oldStatus: string;
newStatus: string;
companyId: string | null;
timestamp: string;
};
const { caseNumber, oldStatus, newStatus, companyId } = payload;
if (!caseNumber || !newStatus || !companyId) {
pluginCtx.logger.warn("onWebhook: malformed payload", {
caseNumber,
newStatus,
companyId,
});
return;
}
pluginCtx.logger.info(
`Webhook: case ${caseNumber} ${oldStatus}${newStatus}`,
{
companyId,
},
);
// Find the Paperclip issue linked to this case number by scanning plugin state.
// State stores: issue.id → case_number (scopeKind=issue, stateKey=legal-case-number)
const issues = await pluginCtx.issues.list({ companyId });
let linkedIssueId: string | null = null;
for (const issue of issues) {
const linkedCase = await pluginCtx.state.get({
scopeKind: "issue",
scopeId: issue.id,
stateKey: "legal-case-number",
});
if (linkedCase === caseNumber) {
linkedIssueId = issue.id;
break;
}
}
if (!linkedIssueId) {
pluginCtx.logger.warn(
`onWebhook: no Paperclip issue linked to case ${caseNumber}`,
);
return;
}
// Status label map (Hebrew)
const statusLabels: Record<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;