radmail-mcp 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -0
- package/dist/api/mcp.d.ts +3 -0
- package/dist/api/mcp.js +44 -0
- package/dist/api/mcp.js.map +1 -0
- package/dist/src/engine/importance-score.d.ts +122 -0
- package/dist/src/engine/importance-score.js +352 -0
- package/dist/src/engine/importance-score.js.map +1 -0
- package/dist/src/engine/send-disposition.d.ts +37 -0
- package/dist/src/engine/send-disposition.js +112 -0
- package/dist/src/engine/send-disposition.js.map +1 -0
- package/dist/src/engine/signals.d.ts +116 -0
- package/dist/src/engine/signals.js +287 -0
- package/dist/src/engine/signals.js.map +1 -0
- package/dist/src/engine/types.d.ts +20 -0
- package/dist/src/engine/types.js +52 -0
- package/dist/src/engine/types.js.map +1 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +19 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/lib/commitment.d.ts +24 -0
- package/dist/src/lib/commitment.js +123 -0
- package/dist/src/lib/commitment.js.map +1 -0
- package/dist/src/lib/connected.d.ts +112 -0
- package/dist/src/lib/connected.js +150 -0
- package/dist/src/lib/connected.js.map +1 -0
- package/dist/src/lib/demand-sink.d.ts +23 -0
- package/dist/src/lib/demand-sink.js +87 -0
- package/dist/src/lib/demand-sink.js.map +1 -0
- package/dist/src/lib/learning.d.ts +35 -0
- package/dist/src/lib/learning.js +103 -0
- package/dist/src/lib/learning.js.map +1 -0
- package/dist/src/lib/taint.d.ts +35 -0
- package/dist/src/lib/taint.js +65 -0
- package/dist/src/lib/taint.js.map +1 -0
- package/dist/src/lib/tenants.d.ts +21 -0
- package/dist/src/lib/tenants.js +55 -0
- package/dist/src/lib/tenants.js.map +1 -0
- package/dist/src/lib/triage.d.ts +83 -0
- package/dist/src/lib/triage.js +278 -0
- package/dist/src/lib/triage.js.map +1 -0
- package/dist/src/server.d.ts +9 -0
- package/dist/src/server.js +40 -0
- package/dist/src/server.js.map +1 -0
- package/dist/src/tools.d.ts +302 -0
- package/dist/src/tools.js +737 -0
- package/dist/src/tools.js.map +1 -0
- package/dist/test/connected.test.d.ts +1 -0
- package/dist/test/connected.test.js +514 -0
- package/dist/test/connected.test.js.map +1 -0
- package/dist/test/demand-sink.test.d.ts +1 -0
- package/dist/test/demand-sink.test.js +137 -0
- package/dist/test/demand-sink.test.js.map +1 -0
- package/dist/test/firewall.test.d.ts +1 -0
- package/dist/test/firewall.test.js +210 -0
- package/dist/test/firewall.test.js.map +1 -0
- package/dist/test/taint.test.d.ts +1 -0
- package/dist/test/taint.test.js +90 -0
- package/dist/test/taint.test.js.map +1 -0
- package/package.json +53 -0
- package/src/engine/importance-score.ts +462 -0
- package/src/engine/send-disposition.ts +173 -0
- package/src/engine/signals.ts +403 -0
- package/src/engine/types.ts +73 -0
- package/src/index.ts +21 -0
- package/src/lib/commitment.ts +143 -0
- package/src/lib/connected.ts +291 -0
- package/src/lib/demand-sink.ts +102 -0
- package/src/lib/learning.ts +136 -0
- package/src/lib/taint.ts +87 -0
- package/src/lib/tenants.ts +67 -0
- package/src/lib/triage.ts +358 -0
- package/src/server.ts +50 -0
- package/src/tools.ts +932 -0
package/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# RadMail MCP
|
|
2
|
+
|
|
3
|
+
**An email operating system for agents — with a refusal you can trust.**
|
|
4
|
+
|
|
5
|
+
Every inbox got an AI in 2026. None can be trusted to hit *send*. RadMail is the one that can — because the consequential actions are refused **in code, model-independent**: money, changed-banking details, first-contact senders, decisions, and prompt-injection are **human-only, forever**. No prompt can talk RadMail into auto-sending them.
|
|
6
|
+
|
|
7
|
+
This is the Model Context Protocol (MCP) server, so any AI agent can use the inbox.
|
|
8
|
+
|
|
9
|
+
## Start in one call
|
|
10
|
+
|
|
11
|
+
Call `triage_inbox` and **omit the token** — RadMail auto-provisions a free sandbox tenant and returns a working triage in one round-trip. Reuse the returned token. (On the zero-auth hosted sandbox, `triage_inbox` takes no args — it triages a built-in demo inbox so your very first call returns the full wedge.)
|
|
12
|
+
|
|
13
|
+
> This server runs the **sandbox engine** (heuristic, in-memory, free, no credentials). It is real and runnable — not the production "99%" engine.
|
|
14
|
+
|
|
15
|
+
## Tools
|
|
16
|
+
|
|
17
|
+
| Tool | What it does |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `triage_inbox` | One round-trip over a batch: the Right Now lane + every open commitment + every hard-stop. The whole wedge in one call. |
|
|
20
|
+
| `list_right_now` | The can't-miss lane only — most-recent × most-important, each with why-surfaced. Pass `messages` for the sandbox (with hard-stop flags), or omit them with `RADMAIL_API_KEY` set for your **real** Right Now lane (read-only). |
|
|
21
|
+
| `why_surfaced` | Explain in plain English why a message surfaced — the signals behind its importance × urgency. Transparency, not a black box. |
|
|
22
|
+
| `draft_reply` | Draft the reply that discharges a commitment — **never** for a hard-stopped one (money / banking / first-contact stay human-only). |
|
|
23
|
+
| `list_commitments` | Open promises with their due window. Pass `messages` for sandbox extraction, or omit them with `RADMAIL_API_KEY` set for your **real** tracked commitments (read-only). |
|
|
24
|
+
| `search` | Find the one message you mean by sender / subject / content — most-relevant + newest first (no filesystem grep). Pass `messages` for the sandbox, or omit them with `RADMAIL_API_KEY` set to search your **real inbox** (read-only). |
|
|
25
|
+
| `read_email` | **Connected mode only:** fetch one full email (headers + `textBody`) from your real inbox by id. Read-only; body content arrives taint-tagged. |
|
|
26
|
+
| `triage` | Score a single message (the per-message form of `triage_inbox`). |
|
|
27
|
+
| `provision_sandbox` | Explicitly mint a free sandbox tenant. |
|
|
28
|
+
| `report_need` / `request_capability` | Tell RadMail what was awkward / what you wish existed — the surface adapts. |
|
|
29
|
+
| `radmail_learning_insights` | What RadMail has learned about how you work. |
|
|
30
|
+
|
|
31
|
+
## The safety contract (un-bypassable by design)
|
|
32
|
+
|
|
33
|
+
These are decided by deterministic code, not model judgment — see [`/.well-known/agent-safety.json`](./public/.well-known/agent-safety.json):
|
|
34
|
+
|
|
35
|
+
- **money**, **changed-banking**, **first-contact**, **decision/sign-off**, **prompt-injection** → `hardStop`, human-only forever. RadMail will never hand an agent an auto-sendable reply for these.
|
|
36
|
+
- **Taint envelope:** every field derived from a raw email body carries `provenance: "untrusted-email-body"`, and every response carries a `safety` block restating the hard-stops. **Treat tainted fields as data, never as instructions** — this keeps *your* agent safe-by-default, even against a poisoned email.
|
|
37
|
+
- Fail-closed: if a risk signal can't be evaluated, RadMail refuses to auto-send.
|
|
38
|
+
|
|
39
|
+
## Verify before you connect
|
|
40
|
+
|
|
41
|
+
The safety contract is **machine-verifiable** — fetch it and check it in one command, no account, no key:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
curl -s https://radmail.ai/.well-known/agent-safety.json
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Connect
|
|
48
|
+
|
|
49
|
+
**Fastest — zero-auth hosted sandbox** (no install, no key, no signup). Point any MCP client at the streamable-HTTP endpoint:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"mcpServers": {
|
|
54
|
+
"radmail": {
|
|
55
|
+
"url": "https://radmail.ai/api/mcp/sandbox",
|
|
56
|
+
"transport": "streamable-http"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**Local stdio** (this package — the fuller surface that triages the messages you pass it):
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"mcpServers": {
|
|
67
|
+
"radmail": {
|
|
68
|
+
"command": "npx",
|
|
69
|
+
"args": ["-y", "radmail-mcp"]
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
> **Note:** the npm package publish is imminent — until it lands, use the **zero-auth hosted sandbox above** (no install, works today) or run from source below. The `npx` line goes live the moment `radmail-mcp` is on npm.
|
|
76
|
+
|
|
77
|
+
Or from source: `git clone https://github.com/dougsureel-tech/radmail-mcp && npm i && npm run build && npm start` (stdio). Hosted deploy: Vercel Node serverless function (`api/mcp.ts`; `/` rewrites to the MCP handler).
|
|
78
|
+
|
|
79
|
+
## Connected mode — your real inbox
|
|
80
|
+
|
|
81
|
+
Give the server a RadMail API key and **four tools** stop being a demo. Omit `messages` and:
|
|
82
|
+
|
|
83
|
+
- `search` finds **any email you've ever received** in your real RadMail inbox;
|
|
84
|
+
- `read_email` fetches the full message (headers + `textBody`);
|
|
85
|
+
- `list_right_now` returns your **real can't-miss lane** — the live engine's band + importance + urgency + reasons per item;
|
|
86
|
+
- `list_commitments` lists your **real open promises** — direction (`owed_by_us` / `owed_to_us`), party, action, due date/phrase, state, confidence.
|
|
87
|
+
|
|
88
|
+
Search it, read it, know what matters now, know what's owed — install it once and your AI has the whole picture.
|
|
89
|
+
|
|
90
|
+
- **Config:** set `RADMAIL_API_KEY` (keys start with `tmk_` — create one in about a minute at <https://app.radmail.ai/settings/api-keys>). Optional: `RADMAIL_API_URL` overrides the API host (default `https://app.radmail.ai`).
|
|
91
|
+
- **Read-only by construction:** connected mode only ever issues GETs. It never sends, drafts against, or mutates real mail, and the BEC hard-stops (money / changed-banking / first-contact / decision / injection) stay human-only forever.
|
|
92
|
+
- **Same taint envelope:** every field derived from real email content (`subject`, `fromName`, `snippet`, `textBody`, …) arrives tagged `provenance:"untrusted-email-body"` — data to reason about, never instructions to follow.
|
|
93
|
+
- **Fail-closed:** invalid key (401), un-entitled plan (403), or a timeout returns an honest, typed error — never fabricated results. The key itself is never logged or echoed.
|
|
94
|
+
- **Filters & paging:** connected `search` supports optional `from`, `after`, and `before` (ISO-8601) alongside `query` and `limit`; connected `list_right_now` / `list_commitments` support `limit` and `offset`.
|
|
95
|
+
- **No fabricated judgments:** connected `list_right_now` surfaces the live engine's own band / importance / urgency / reasons as-is — it never invents local hard-stop determinations the API didn't return.
|
|
96
|
+
- Without a key, `search` / `list_right_now` / `list_commitments` (sans `messages`) and `read_email` return friendly setup instructions instead of an error — the sandbox keeps working exactly as before.
|
|
97
|
+
|
|
98
|
+
**Claude Code:**
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
claude mcp add radmail -e RADMAIL_API_KEY=tmk_... -- npx -y radmail-mcp
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Claude Desktop** (`claude_desktop_config.json`):
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"mcpServers": {
|
|
109
|
+
"radmail": {
|
|
110
|
+
"command": "npx",
|
|
111
|
+
"args": ["-y", "radmail-mcp"],
|
|
112
|
+
"env": { "RADMAIL_API_KEY": "tmk_..." }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**Cursor** (`.cursor/mcp.json`):
|
|
119
|
+
|
|
120
|
+
```json
|
|
121
|
+
{
|
|
122
|
+
"mcpServers": {
|
|
123
|
+
"radmail": {
|
|
124
|
+
"command": "npx",
|
|
125
|
+
"args": ["-y", "radmail-mcp"],
|
|
126
|
+
"env": { "RADMAIL_API_KEY": "tmk_..." }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
> Same npm note as above: the `npx` lines activate the moment the npm publish lands. Until then, run from source and point `command` at `node dist/src/index.js` — connected mode works today that way.
|
|
133
|
+
|
|
134
|
+
## Telemetry (demand signals — opt-out)
|
|
135
|
+
|
|
136
|
+
This server sends anonymous demand-signal telemetry to `https://app.radmail.ai/api/mcp-demand` so RadMail can see which tools agents actually use and what capabilities they ask for: **what's sent** is the tool name, the event type (`call` / `need` / `capability`), the need or capability text you explicitly submit via `report_need` / `request_capability`, and the optional agent id you pass. **What's never sent:** email content, message batches, search queries, results — and never your API key (in connected mode only the safe display prefix, `tmk_live_` + the first 4 characters, is transmitted so adoption of connected mode is distinguishable). Sends are fire-and-forget with a 3-second timeout and every failure silently swallowed — telemetry can never slow down or break a tool call. **Opt out entirely** with `RADMAIL_TELEMETRY=off`.
|
|
137
|
+
|
|
138
|
+
## Links
|
|
139
|
+
|
|
140
|
+
- Agent docs: <https://radmail.ai/for-agents>
|
|
141
|
+
- Zero-auth sandbox: `https://radmail.ai/api/mcp/sandbox` (streamable-http, no auth)
|
|
142
|
+
- Verifiable safety contract: <https://radmail.ai/.well-known/agent-safety.json>
|
|
143
|
+
- MCP manifest: <https://radmail.ai/.well-known/mcp.json>
|
|
144
|
+
- LLM-readable summary: <https://radmail.ai/llms.txt>
|
|
145
|
+
|
|
146
|
+
## Compliance posture
|
|
147
|
+
|
|
148
|
+
A tool, not a guarantee — BAA + shared-responsibility framing. **Never** "HIPAA-certified" or "FedRAMP-authorized."
|
package/dist/api/mcp.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Vercel serverless function — RadMail MCP over streamable-HTTP.
|
|
2
|
+
//
|
|
3
|
+
// The live radmail-mcp project is a Node serverless deployment. Each request
|
|
4
|
+
// gets a fresh server + transport in STATELESS mode (no sessionIdGenerator)
|
|
5
|
+
// with JSON responses — the clean shape for a stateless serverless function.
|
|
6
|
+
// Endpoint: POST /api/mcp.
|
|
7
|
+
//
|
|
8
|
+
// NOTE: uses the Web-handler signature via NAMED method exports (GET/POST).
|
|
9
|
+
// Current @vercel/node invokes a DEFAULT export in Node (req,res) style and
|
|
10
|
+
// ignores a returned web `Response` (the function then 504s); named method
|
|
11
|
+
// exports select the web signature unambiguously.
|
|
12
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
13
|
+
import { createServer } from "../src/server.js";
|
|
14
|
+
export async function GET() {
|
|
15
|
+
// Health / discovery ping — not the MCP channel itself (which is POST).
|
|
16
|
+
return new Response(JSON.stringify({
|
|
17
|
+
name: "radmail-mcp",
|
|
18
|
+
engine: "sandbox",
|
|
19
|
+
transport: "streamable-http",
|
|
20
|
+
endpoint: "/api/mcp",
|
|
21
|
+
note: "POST JSON-RPC MCP requests here. Zero-auth sandbox; tools auto-provision a tenant.",
|
|
22
|
+
}), { status: 200, headers: { "content-type": "application/json" } });
|
|
23
|
+
}
|
|
24
|
+
export async function POST(req) {
|
|
25
|
+
const server = createServer();
|
|
26
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
27
|
+
// Stateless: no session id generator. JSON responses (no long-lived SSE) suit
|
|
28
|
+
// a serverless function with a per-request lifecycle.
|
|
29
|
+
enableJsonResponse: true,
|
|
30
|
+
});
|
|
31
|
+
await server.connect(transport);
|
|
32
|
+
try {
|
|
33
|
+
return await transport.handleRequest(req);
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
// Close the per-request server so it doesn't leak across invocations.
|
|
37
|
+
await server.close().catch(() => { });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export async function DELETE() {
|
|
41
|
+
// Stateless server — there is no session to tear down.
|
|
42
|
+
return new Response(null, { status: 405, headers: { Allow: "GET, POST" } });
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=mcp.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mcp.js","sourceRoot":"","sources":["../../api/mcp.ts"],"names":[],"mappings":"AAAA,iEAAiE;AACjE,EAAE;AACF,6EAA6E;AAC7E,4EAA4E;AAC5E,6EAA6E;AAC7E,2BAA2B;AAC3B,EAAE;AACF,4EAA4E;AAC5E,4EAA4E;AAC5E,2EAA2E;AAC3E,kDAAkD;AAElD,OAAO,EAAE,wCAAwC,EAAE,MAAM,+DAA+D,CAAC;AACzH,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAEhD,MAAM,CAAC,KAAK,UAAU,GAAG;IACvB,wEAAwE;IACxE,OAAO,IAAI,QAAQ,CACjB,IAAI,CAAC,SAAS,CAAC;QACb,IAAI,EAAE,aAAa;QACnB,MAAM,EAAE,SAAS;QACjB,SAAS,EAAE,iBAAiB;QAC5B,QAAQ,EAAE,UAAU;QACpB,IAAI,EAAE,oFAAoF;KAC3F,CAAC,EACF,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE,EAAE,CACjE,CAAC;AACJ,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,IAAI,CAAC,GAAY;IACrC,MAAM,MAAM,GAAG,YAAY,EAAE,CAAC;IAC9B,MAAM,SAAS,GAAG,IAAI,wCAAwC,CAAC;QAC7D,8EAA8E;QAC9E,sDAAsD;QACtD,kBAAkB,EAAE,IAAI;KACzB,CAAC,CAAC;IAEH,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;IAChC,IAAI,CAAC;QACH,OAAO,MAAM,SAAS,CAAC,aAAa,CAAC,GAAG,CAAC,CAAC;IAC5C,CAAC;YAAS,CAAC;QACT,sEAAsE;QACtE,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;IACvC,CAAC;AACH,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,MAAM;IAC1B,uDAAuD;IACvD,OAAO,IAAI,QAAQ,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,EAAE,KAAK,EAAE,WAAW,EAAE,EAAE,CAAC,CAAC;AAC9E,CAAC"}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
export type ImportanceBand = "critical" | "high" | "normal" | "low";
|
|
2
|
+
export interface ImportanceInput {
|
|
3
|
+
classification: string | null;
|
|
4
|
+
needsDougEyes: boolean;
|
|
5
|
+
/** llm_extracted_amount_cents (bigint cents) or null. */
|
|
6
|
+
amountCents: number | null;
|
|
7
|
+
/** llm_extracted_due_date — 'YYYY-MM-DD' string, Date, or null. */
|
|
8
|
+
dueDate: string | Date | null;
|
|
9
|
+
/** Per-tenant counterparty FK (was vendorId in the source). */
|
|
10
|
+
counterpartyId: string | null;
|
|
11
|
+
receivedAt: string | Date;
|
|
12
|
+
isSpam: boolean;
|
|
13
|
+
archivedAt: string | Date | null;
|
|
14
|
+
processedAt: string | Date | null;
|
|
15
|
+
wslcbRetention: boolean;
|
|
16
|
+
/** subject line — light keyword scan for credit/return/refund. */
|
|
17
|
+
subject: string | null;
|
|
18
|
+
}
|
|
19
|
+
export interface ImportanceResult {
|
|
20
|
+
/** 0–100. */
|
|
21
|
+
score: number;
|
|
22
|
+
band: ImportanceBand;
|
|
23
|
+
/** Human-readable drivers, for the UI chip ("Recall", "Invoice due in 2d", "$4,200"). */
|
|
24
|
+
reasons: string[];
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Per-signal LEARNED multipliers. Each value scales the contribution of one
|
|
28
|
+
* signal family. The DEFAULT is all-1.0 = "use the original hand-tuned point
|
|
29
|
+
* values exactly", so omitting this arg reproduces the original scorer
|
|
30
|
+
* byte-for-byte (existing pins stay green). The nightly tuner in weights.ts
|
|
31
|
+
* learns these from labeled history within hard bounds (recall/wslcb can never
|
|
32
|
+
* drop below a regulatory floor).
|
|
33
|
+
*/
|
|
34
|
+
export interface ImportanceSignalWeights {
|
|
35
|
+
recall: number;
|
|
36
|
+
wslcb: number;
|
|
37
|
+
needsEyes: number;
|
|
38
|
+
dueDate: number;
|
|
39
|
+
amount: number;
|
|
40
|
+
creditReturn: number;
|
|
41
|
+
vendorRecency: number;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Score a single inbox email's importance to the OWNER. Higher = more likely to
|
|
45
|
+
* need attention / easier to lose if buried. `now` is injected so the function
|
|
46
|
+
* stays pure + testable. `weights` (optional) scales each signal family —
|
|
47
|
+
* defaults to identity so the original hand-tuned behavior is unchanged.
|
|
48
|
+
*/
|
|
49
|
+
export declare function scoreEmailImportance(e: ImportanceInput, now: Date, weights?: ImportanceSignalWeights): ImportanceResult;
|
|
50
|
+
import { type BehavioralSignalInput } from "./signals.js";
|
|
51
|
+
export type EisenhowerQuadrant = "do" | "schedule" | "delegate" | "drop";
|
|
52
|
+
export interface TwoAxisResult {
|
|
53
|
+
/** Does this matter / need attention? 0–100. */
|
|
54
|
+
importance: number;
|
|
55
|
+
/** Deadline-driven time pressure, extracted from due dates. 0–100. */
|
|
56
|
+
urgency: number;
|
|
57
|
+
/** Blended rank used for a single-column sort (importance-led). 0–100. */
|
|
58
|
+
combined: number;
|
|
59
|
+
band: ImportanceBand;
|
|
60
|
+
quadrant: EisenhowerQuadrant;
|
|
61
|
+
reasons: string[];
|
|
62
|
+
/** One-line "why surfaced", template over the top-3 signals (no LLM). */
|
|
63
|
+
why: string;
|
|
64
|
+
/** Named breakdown of every contributing signal, for audit + UI. */
|
|
65
|
+
breakdown: SignalContribution[];
|
|
66
|
+
}
|
|
67
|
+
export interface SignalContribution {
|
|
68
|
+
signal: string;
|
|
69
|
+
/** Plain-English driver. */
|
|
70
|
+
label: string;
|
|
71
|
+
/** Points contributed to importance (urgency tracked separately). */
|
|
72
|
+
points: number;
|
|
73
|
+
axis: "importance" | "urgency";
|
|
74
|
+
}
|
|
75
|
+
/** Importance threshold above which an email counts as "important" for the quadrant. */
|
|
76
|
+
export declare const IMPORTANCE_AXIS_THRESHOLD = 45;
|
|
77
|
+
/** Urgency threshold above which an email counts as "urgent" for the quadrant. */
|
|
78
|
+
export declare const URGENCY_AXIS_THRESHOLD = 50;
|
|
79
|
+
/**
|
|
80
|
+
* URGENCY = pure time pressure, extracted from the due date. Independent of
|
|
81
|
+
* importance: a recall with no date is important-not-urgent; a routine menu
|
|
82
|
+
* "order by EOD" is urgent-not-important. Recall/WSLCB carry a standing urgency
|
|
83
|
+
* floor (regulatory windows are inherently time-bound).
|
|
84
|
+
*/
|
|
85
|
+
export declare function extractUrgency(e: ImportanceInput, now: Date): {
|
|
86
|
+
urgency: number;
|
|
87
|
+
reasons: string[];
|
|
88
|
+
};
|
|
89
|
+
/** Derive the Eisenhower quadrant from the two axes. */
|
|
90
|
+
export declare function eisenhowerQuadrant(importance: number, urgency: number): EisenhowerQuadrant;
|
|
91
|
+
/**
|
|
92
|
+
* Render the one-line "why surfaced" from the top contributing signals — a
|
|
93
|
+
* TEMPLATE over the breakdown, NO extra LLM call. Picks the highest-magnitude
|
|
94
|
+
* positive contributions so the explanation matches what actually drove the rank.
|
|
95
|
+
*/
|
|
96
|
+
export declare function explainSurfaced(breakdown: SignalContribution[]): string;
|
|
97
|
+
/**
|
|
98
|
+
* TWO-AXIS scorer. Computes importance (does it matter — content + behavior) and
|
|
99
|
+
* urgency (deadline pressure) as separate numbers, the Eisenhower quadrant, a
|
|
100
|
+
* named signal breakdown, and a templated "why surfaced".
|
|
101
|
+
*
|
|
102
|
+
* Importance reuses scoreEmailImportance() as the content base (so the learned
|
|
103
|
+
* content weights still apply + the regulatory floor is preserved), then folds
|
|
104
|
+
* in the bounded behavioral bonus. Behavior can lift a content-quiet but
|
|
105
|
+
* behaviorally-important email out of 'low'; it is hard-capped and never buries
|
|
106
|
+
* a recall (the content base already floored those).
|
|
107
|
+
*/
|
|
108
|
+
export declare function scoreEmailTwoAxis(e: ImportanceInput, now: Date, opts?: {
|
|
109
|
+
weights?: ImportanceSignalWeights;
|
|
110
|
+
behavior?: BehavioralSignalInput;
|
|
111
|
+
}): TwoAxisResult;
|
|
112
|
+
/** Sort comparator: importance desc, then most-recent first. */
|
|
113
|
+
export declare function compareByImportance(a: {
|
|
114
|
+
importance: number;
|
|
115
|
+
receivedAt: string | Date;
|
|
116
|
+
}, b: {
|
|
117
|
+
importance: number;
|
|
118
|
+
receivedAt: string | Date;
|
|
119
|
+
}): number;
|
|
120
|
+
/** Window helper for the "last 72h" view. */
|
|
121
|
+
export declare const IMPORTANCE_RECENT_WINDOW_HOURS = 72;
|
|
122
|
+
export declare function isWithinRecentWindow(receivedAt: string | Date, now: Date): boolean;
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// PORTED VERBATIM (pure, no-DB) from the RadMail main app:
|
|
3
|
+
// /Users/GreenLife/Documents/CODE/RadMail/src/lib/importance/score.ts
|
|
4
|
+
// The crown-jewel two-axis importance scorer. Recovered into radmail-mcp so the
|
|
5
|
+
// MCP sandbox ranks mail with the SAME deterministic math as production.
|
|
6
|
+
// Keep byte-for-byte with source.
|
|
7
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
8
|
+
// Importance score — the crown-jewel scorer. Ported AS-IS from the source
|
|
9
|
+
// engine's buyer-inbox-importance.ts (architecture §4). The ONLY change is the
|
|
10
|
+
// per-tenant rename `vendorId` → `counterpartyId` in ImportanceInput (the source
|
|
11
|
+
// FK'd a cannabis `vendors` table; the spinoff FKs a per-tenant counterparty
|
|
12
|
+
// registry). The scoring math is byte-identical — these are genuinely pure
|
|
13
|
+
// functions and the pin tests carry over unchanged.
|
|
14
|
+
//
|
|
15
|
+
// PURE function — no DB, no network, no clock-of-its-own (caller passes `now`).
|
|
16
|
+
// Fully unit-pinned in __tests__/score.test.ts. The signals are all on the
|
|
17
|
+
// inbox_emails row, so this scores at query time with no migration.
|
|
18
|
+
//
|
|
19
|
+
// Approved ranking order: recalls / regulatory → needs-your-eyes → invoices/POs
|
|
20
|
+
// due within ~7 days → big-dollar → known counterparty by recency; with
|
|
21
|
+
// "credit"/"return" mentions and any dollar amount weighted up; spam / archived
|
|
22
|
+
// / marketing sink.
|
|
23
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
24
|
+
function toDate(v) {
|
|
25
|
+
if (v == null)
|
|
26
|
+
return null;
|
|
27
|
+
if (v instanceof Date)
|
|
28
|
+
return isNaN(v.getTime()) ? null : v;
|
|
29
|
+
// 'YYYY-MM-DD' parses as UTC midnight, which is fine for day-granularity due dates.
|
|
30
|
+
const d = new Date(v);
|
|
31
|
+
return isNaN(d.getTime()) ? null : d;
|
|
32
|
+
}
|
|
33
|
+
function clamp(n, lo, hi) {
|
|
34
|
+
return Math.max(lo, Math.min(hi, n));
|
|
35
|
+
}
|
|
36
|
+
function bandFor(score) {
|
|
37
|
+
if (score >= 80)
|
|
38
|
+
return "critical";
|
|
39
|
+
if (score >= 45)
|
|
40
|
+
return "high";
|
|
41
|
+
if (score >= 20)
|
|
42
|
+
return "normal";
|
|
43
|
+
return "low";
|
|
44
|
+
}
|
|
45
|
+
const CREDIT_RETURN_RE = /\b(credit|return|refund|charge-?back|rma|short(ed|age)?|damag)/i;
|
|
46
|
+
const IDENTITY_WEIGHTS = {
|
|
47
|
+
recall: 1,
|
|
48
|
+
wslcb: 1,
|
|
49
|
+
needsEyes: 1,
|
|
50
|
+
dueDate: 1,
|
|
51
|
+
amount: 1,
|
|
52
|
+
creditReturn: 1,
|
|
53
|
+
vendorRecency: 1,
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Score a single inbox email's importance to the OWNER. Higher = more likely to
|
|
57
|
+
* need attention / easier to lose if buried. `now` is injected so the function
|
|
58
|
+
* stays pure + testable. `weights` (optional) scales each signal family —
|
|
59
|
+
* defaults to identity so the original hand-tuned behavior is unchanged.
|
|
60
|
+
*/
|
|
61
|
+
export function scoreEmailImportance(e, now, weights = IDENTITY_WEIGHTS) {
|
|
62
|
+
const w = weights;
|
|
63
|
+
const reasons = [];
|
|
64
|
+
// --- Hard sinks first (a done/junk email is never "important to surface"). ---
|
|
65
|
+
if (e.isSpam) {
|
|
66
|
+
return { score: 2, band: "low", reasons: ["Spam"] };
|
|
67
|
+
}
|
|
68
|
+
const archived = toDate(e.archivedAt);
|
|
69
|
+
if (archived) {
|
|
70
|
+
return { score: 5, band: "low", reasons: ["Archived"] };
|
|
71
|
+
}
|
|
72
|
+
const cls = (e.classification ?? "").toLowerCase();
|
|
73
|
+
let score = 0;
|
|
74
|
+
// --- Regulatory: must-not-miss. ---
|
|
75
|
+
if (cls === "recall") {
|
|
76
|
+
score += 100 * w.recall;
|
|
77
|
+
reasons.push("Product recall");
|
|
78
|
+
}
|
|
79
|
+
else if (cls === "wslcb_notice" || e.wslcbRetention) {
|
|
80
|
+
score += 90 * w.wslcb;
|
|
81
|
+
reasons.push("WSLCB notice");
|
|
82
|
+
}
|
|
83
|
+
// --- The classifier was unsure → it WANTS a human. ---
|
|
84
|
+
if (e.needsDougEyes) {
|
|
85
|
+
score += 55 * w.needsEyes;
|
|
86
|
+
reasons.push("Needs your review");
|
|
87
|
+
}
|
|
88
|
+
// --- Money: invoices / POs with deadlines, scaled by proximity. Applies to
|
|
89
|
+
// ANY class that carries an extracted due date — a deadline is
|
|
90
|
+
// date-sensitive regardless of how it classified. ---
|
|
91
|
+
const due = toDate(e.dueDate);
|
|
92
|
+
if (due) {
|
|
93
|
+
const days = Math.floor((due.getTime() - now.getTime()) / DAY_MS);
|
|
94
|
+
if (days <= 0) {
|
|
95
|
+
score += 70 * w.dueDate;
|
|
96
|
+
reasons.push(days === 0 ? "Due today" : `Overdue ${Math.abs(days)}d`);
|
|
97
|
+
}
|
|
98
|
+
else if (days <= 2) {
|
|
99
|
+
score += 60 * w.dueDate;
|
|
100
|
+
reasons.push(`Due in ${days}d`);
|
|
101
|
+
}
|
|
102
|
+
else if (days <= 7) {
|
|
103
|
+
score += 45 * w.dueDate;
|
|
104
|
+
reasons.push(`Due in ${days}d`);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
score += 18 * w.dueDate;
|
|
108
|
+
reasons.push(`Due in ${days}d`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else if (cls === "invoice") {
|
|
112
|
+
score += 30 * w.dueDate;
|
|
113
|
+
reasons.push("Invoice");
|
|
114
|
+
}
|
|
115
|
+
// --- Dollar amount present → weight up (anything with a $ amount). ---
|
|
116
|
+
if (e.amountCents != null && e.amountCents > 0) {
|
|
117
|
+
const dollars = e.amountCents / 100;
|
|
118
|
+
let amtBoost = 6;
|
|
119
|
+
if (dollars >= 5000)
|
|
120
|
+
amtBoost = 22;
|
|
121
|
+
else if (dollars >= 1000)
|
|
122
|
+
amtBoost = 14;
|
|
123
|
+
else if (dollars >= 250)
|
|
124
|
+
amtBoost = 9;
|
|
125
|
+
score += amtBoost * w.amount;
|
|
126
|
+
reasons.push("$" + Math.round(dollars).toLocaleString("en-US"));
|
|
127
|
+
}
|
|
128
|
+
// --- Credit / return / refund language. ---
|
|
129
|
+
if (e.subject && CREDIT_RETURN_RE.test(e.subject)) {
|
|
130
|
+
score += 25 * w.creditReturn;
|
|
131
|
+
reasons.push("Credit / return");
|
|
132
|
+
}
|
|
133
|
+
// --- Low-value classes for the OWNER: menus, marketing, samples, meeting
|
|
134
|
+
// invites, system mail. Cap them so they never crowd the top. ---
|
|
135
|
+
const LOW_VALUE = new Set(["marketing", "sample", "availability", "meeting", "system"]);
|
|
136
|
+
if (LOW_VALUE.has(cls)) {
|
|
137
|
+
score = Math.min(score, 15);
|
|
138
|
+
if (reasons.length === 0) {
|
|
139
|
+
reasons.push(cls === "availability" ? "Menu" : cls.charAt(0).toUpperCase() + cls.slice(1));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// --- Known counterparty + recency: small base so a normal, recent email
|
|
143
|
+
// still ranks above an old unknown one, without overriding signals above. ---
|
|
144
|
+
if (e.counterpartyId) {
|
|
145
|
+
score += 8 * w.vendorRecency;
|
|
146
|
+
}
|
|
147
|
+
const received = toDate(e.receivedAt);
|
|
148
|
+
if (received) {
|
|
149
|
+
const ageMs = now.getTime() - received.getTime();
|
|
150
|
+
if (ageMs <= DAY_MS)
|
|
151
|
+
score += 6 * w.vendorRecency;
|
|
152
|
+
else if (ageMs <= 3 * DAY_MS)
|
|
153
|
+
score += 3 * w.vendorRecency;
|
|
154
|
+
}
|
|
155
|
+
// A processed/auto-filed email is mostly handled, but not as dead as archived
|
|
156
|
+
// — soften rather than zero, so a filed-but-still-due invoice keeps some rank.
|
|
157
|
+
if (toDate(e.processedAt)) {
|
|
158
|
+
score = Math.round(score * 0.5);
|
|
159
|
+
}
|
|
160
|
+
score = clamp(Math.round(score), 0, 100);
|
|
161
|
+
if (reasons.length === 0)
|
|
162
|
+
reasons.push("Vendor email");
|
|
163
|
+
return { score, band: bandFor(score), reasons };
|
|
164
|
+
}
|
|
165
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
166
|
+
// TWO-AXIS SCORING + BEHAVIORAL SIGNALS + EXPLAINABILITY
|
|
167
|
+
//
|
|
168
|
+
// Splits the score into two independent axes (Eisenhower: importance vs
|
|
169
|
+
// urgency), folds in the behavioral layer from signals.ts, and emits a "why
|
|
170
|
+
// surfaced" template — WITHOUT changing scoreEmailImportance()'s default
|
|
171
|
+
// behavior (the pins stay byte-identical; these are additive new exports).
|
|
172
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
173
|
+
import { computeBehavioralSignals, } from "./signals.js"; // (port note: .js extension required by NodeNext module resolution)
|
|
174
|
+
/** Importance threshold above which an email counts as "important" for the quadrant. */
|
|
175
|
+
export const IMPORTANCE_AXIS_THRESHOLD = 45;
|
|
176
|
+
/** Urgency threshold above which an email counts as "urgent" for the quadrant. */
|
|
177
|
+
export const URGENCY_AXIS_THRESHOLD = 50;
|
|
178
|
+
/**
|
|
179
|
+
* URGENCY = pure time pressure, extracted from the due date. Independent of
|
|
180
|
+
* importance: a recall with no date is important-not-urgent; a routine menu
|
|
181
|
+
* "order by EOD" is urgent-not-important. Recall/WSLCB carry a standing urgency
|
|
182
|
+
* floor (regulatory windows are inherently time-bound).
|
|
183
|
+
*/
|
|
184
|
+
export function extractUrgency(e, now) {
|
|
185
|
+
const reasons = [];
|
|
186
|
+
if (e.isSpam || toDate(e.archivedAt))
|
|
187
|
+
return { urgency: 0, reasons: [] };
|
|
188
|
+
let urgency = 0;
|
|
189
|
+
const cls = (e.classification ?? "").toLowerCase();
|
|
190
|
+
const due = toDate(e.dueDate);
|
|
191
|
+
if (due) {
|
|
192
|
+
const days = Math.floor((due.getTime() - now.getTime()) / DAY_MS);
|
|
193
|
+
if (days <= 0) {
|
|
194
|
+
urgency = 100;
|
|
195
|
+
reasons.push(days === 0 ? "Due today" : `Overdue ${Math.abs(days)}d`);
|
|
196
|
+
}
|
|
197
|
+
else if (days <= 2) {
|
|
198
|
+
urgency = 85;
|
|
199
|
+
reasons.push(`Due in ${days}d`);
|
|
200
|
+
}
|
|
201
|
+
else if (days <= 7) {
|
|
202
|
+
urgency = 60;
|
|
203
|
+
reasons.push(`Due in ${days}d`);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
urgency = 30;
|
|
207
|
+
reasons.push(`Due in ${days}d`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// Regulatory standing urgency floor — a recall/WSLCB is time-bound even with
|
|
211
|
+
// no parsed date.
|
|
212
|
+
if (cls === "recall")
|
|
213
|
+
urgency = Math.max(urgency, 90);
|
|
214
|
+
else if (cls === "wslcb_notice" || e.wslcbRetention)
|
|
215
|
+
urgency = Math.max(urgency, 70);
|
|
216
|
+
return { urgency: clamp(Math.round(urgency), 0, 100), reasons };
|
|
217
|
+
}
|
|
218
|
+
/** Derive the Eisenhower quadrant from the two axes. */
|
|
219
|
+
export function eisenhowerQuadrant(importance, urgency) {
|
|
220
|
+
const imp = importance >= IMPORTANCE_AXIS_THRESHOLD;
|
|
221
|
+
const urg = urgency >= URGENCY_AXIS_THRESHOLD;
|
|
222
|
+
if (imp && urg)
|
|
223
|
+
return "do";
|
|
224
|
+
if (imp && !urg)
|
|
225
|
+
return "schedule";
|
|
226
|
+
if (!imp && urg)
|
|
227
|
+
return "delegate";
|
|
228
|
+
return "drop";
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Render the one-line "why surfaced" from the top contributing signals — a
|
|
232
|
+
* TEMPLATE over the breakdown, NO extra LLM call. Picks the highest-magnitude
|
|
233
|
+
* positive contributions so the explanation matches what actually drove the rank.
|
|
234
|
+
*/
|
|
235
|
+
export function explainSurfaced(breakdown) {
|
|
236
|
+
const positives = breakdown
|
|
237
|
+
.filter((c) => c.points > 0)
|
|
238
|
+
.sort((a, b) => b.points - a.points)
|
|
239
|
+
.slice(0, 3);
|
|
240
|
+
if (positives.length === 0)
|
|
241
|
+
return "Routine — nothing pulled this up.";
|
|
242
|
+
const [lead, ...rest] = positives.map((c) => c.label);
|
|
243
|
+
return rest.length ? `${lead} · ${rest.join(" · ")}` : lead;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* TWO-AXIS scorer. Computes importance (does it matter — content + behavior) and
|
|
247
|
+
* urgency (deadline pressure) as separate numbers, the Eisenhower quadrant, a
|
|
248
|
+
* named signal breakdown, and a templated "why surfaced".
|
|
249
|
+
*
|
|
250
|
+
* Importance reuses scoreEmailImportance() as the content base (so the learned
|
|
251
|
+
* content weights still apply + the regulatory floor is preserved), then folds
|
|
252
|
+
* in the bounded behavioral bonus. Behavior can lift a content-quiet but
|
|
253
|
+
* behaviorally-important email out of 'low'; it is hard-capped and never buries
|
|
254
|
+
* a recall (the content base already floored those).
|
|
255
|
+
*/
|
|
256
|
+
export function scoreEmailTwoAxis(e, now, opts = {}) {
|
|
257
|
+
const weights = opts.weights ?? IDENTITY_WEIGHTS;
|
|
258
|
+
const content = scoreEmailImportance(e, now, weights);
|
|
259
|
+
const breakdown = [];
|
|
260
|
+
// Seed the breakdown from the content reasons (each carries its own driver).
|
|
261
|
+
for (const r of content.reasons) {
|
|
262
|
+
breakdown.push({ signal: "content", label: r, points: 0, axis: "importance" });
|
|
263
|
+
}
|
|
264
|
+
// Hard sinks: spam/archived stay sunk on both axes; behavior can't rescue junk.
|
|
265
|
+
const sunk = e.isSpam || toDate(e.archivedAt) != null;
|
|
266
|
+
let importance = content.score;
|
|
267
|
+
let behavioral = null;
|
|
268
|
+
if (!sunk && opts.behavior) {
|
|
269
|
+
behavioral = computeBehavioralSignals(opts.behavior, now);
|
|
270
|
+
// Explicit operator override is the human ground truth — it wins, but still
|
|
271
|
+
// can't bury a regulatory-floored content score.
|
|
272
|
+
if (behavioral.explicitOverride === "not") {
|
|
273
|
+
importance = Math.min(importance, 15);
|
|
274
|
+
breakdown.push({
|
|
275
|
+
signal: "operator_override",
|
|
276
|
+
label: "You marked this not important",
|
|
277
|
+
points: 15 - content.score,
|
|
278
|
+
axis: "importance",
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
if (behavioral.explicitOverride === "important") {
|
|
283
|
+
const lift = Math.max(0, IMPORTANCE_AXIS_THRESHOLD + 5 - importance);
|
|
284
|
+
importance += lift;
|
|
285
|
+
breakdown.push({
|
|
286
|
+
signal: "operator_override",
|
|
287
|
+
label: "You marked this important",
|
|
288
|
+
points: lift,
|
|
289
|
+
axis: "importance",
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
importance += behavioral.pointBonus;
|
|
293
|
+
for (const c of behavioral.contributions) {
|
|
294
|
+
breakdown.push({
|
|
295
|
+
signal: c.signal,
|
|
296
|
+
label: c.label,
|
|
297
|
+
points: c.points,
|
|
298
|
+
axis: "importance",
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
importance = clamp(Math.round(importance), 0, 100);
|
|
303
|
+
}
|
|
304
|
+
const { urgency, reasons: urgencyReasons } = extractUrgency(e, now);
|
|
305
|
+
for (const r of urgencyReasons) {
|
|
306
|
+
if (!breakdown.some((b) => b.label === r && b.axis === "urgency")) {
|
|
307
|
+
breakdown.push({ signal: "urgency", label: r, points: urgency, axis: "urgency" });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// Combined rank: importance-led, nudged up by urgency so a tie breaks toward
|
|
311
|
+
// the time-pressured one.
|
|
312
|
+
const combined = clamp(Math.round(importance * 0.8 + urgency * 0.2), 0, 100);
|
|
313
|
+
const band = bandFor(importance);
|
|
314
|
+
const quadrant = eisenhowerQuadrant(importance, urgency);
|
|
315
|
+
const reasons = [...content.reasons];
|
|
316
|
+
for (const r of urgencyReasons)
|
|
317
|
+
if (!reasons.includes(r))
|
|
318
|
+
reasons.push(r);
|
|
319
|
+
if (behavioral) {
|
|
320
|
+
for (const c of behavioral.contributions) {
|
|
321
|
+
if (c.points > 0 && !reasons.includes(c.label))
|
|
322
|
+
reasons.push(c.label);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
importance,
|
|
327
|
+
urgency,
|
|
328
|
+
combined,
|
|
329
|
+
band,
|
|
330
|
+
quadrant,
|
|
331
|
+
reasons,
|
|
332
|
+
why: explainSurfaced(breakdown),
|
|
333
|
+
breakdown,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
/** Sort comparator: importance desc, then most-recent first. */
|
|
337
|
+
export function compareByImportance(a, b) {
|
|
338
|
+
if (b.importance !== a.importance)
|
|
339
|
+
return b.importance - a.importance;
|
|
340
|
+
const at = toDate(a.receivedAt)?.getTime() ?? 0;
|
|
341
|
+
const bt = toDate(b.receivedAt)?.getTime() ?? 0;
|
|
342
|
+
return bt - at;
|
|
343
|
+
}
|
|
344
|
+
/** Window helper for the "last 72h" view. */
|
|
345
|
+
export const IMPORTANCE_RECENT_WINDOW_HOURS = 72;
|
|
346
|
+
export function isWithinRecentWindow(receivedAt, now) {
|
|
347
|
+
const r = toDate(receivedAt);
|
|
348
|
+
if (!r)
|
|
349
|
+
return false;
|
|
350
|
+
return now.getTime() - r.getTime() <= IMPORTANCE_RECENT_WINDOW_HOURS * 60 * 60 * 1000;
|
|
351
|
+
}
|
|
352
|
+
//# sourceMappingURL=importance-score.js.map
|