@method-ai-hq/sdk 0.0.0-stage → 0.14.3
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/LICENSE +21 -0
- package/README.md +23 -2
- package/dist/packages/contracts/src/identity.d.ts +16 -0
- package/dist/packages/contracts/src/identity.js +41 -0
- package/dist/packages/contracts/src/method-package.d.ts +71 -0
- package/dist/packages/contracts/src/method-package.js +22 -0
- package/dist/packages/method-document/src/conversion-report.d.ts +138 -0
- package/dist/packages/method-document/src/conversion-report.js +23 -0
- package/dist/packages/sdk/examples/daily-briefing/README.md +31 -0
- package/dist/packages/sdk/examples/daily-briefing/TASK.md +3 -0
- package/dist/packages/sdk/examples/daily-briefing/approved-report.md +138 -0
- package/dist/packages/sdk/examples/daily-briefing/briefing_artifacts.py +39 -0
- package/dist/packages/sdk/examples/daily-briefing/briefing_check_inputs.py +47 -0
- package/dist/packages/sdk/examples/daily-briefing/briefing_files.py +31 -0
- package/dist/packages/sdk/examples/daily-briefing/briefing_manifest.py +23 -0
- package/dist/packages/sdk/examples/daily-briefing/briefing_markdown.py +67 -0
- package/dist/packages/sdk/examples/daily-briefing/briefing_progress.py +18 -0
- package/dist/packages/sdk/examples/daily-briefing/briefing_render.py +71 -0
- package/dist/packages/sdk/examples/daily-briefing/briefing_sessions.py +37 -0
- package/dist/packages/sdk/examples/daily-briefing/briefing_times.py +158 -0
- package/dist/packages/sdk/examples/daily-briefing/briefing_validation.py +101 -0
- package/dist/packages/sdk/examples/daily-briefing/briefing_website.py +215 -0
- package/dist/packages/sdk/examples/daily-briefing/checks.json +19 -0
- package/dist/packages/sdk/examples/daily-briefing/daily-briefing.method +158 -0
- package/dist/packages/sdk/examples/daily-briefing/inputs.json +1 -0
- package/dist/packages/sdk/examples/daily-briefing/package-lock.json +10 -0
- package/dist/packages/sdk/examples/daily-briefing/package.json +4 -0
- package/dist/packages/sdk/examples/daily-briefing/pyproject.toml +8 -0
- package/dist/packages/sdk/examples/daily-briefing/reader/reader.css +35 -0
- package/dist/packages/sdk/examples/daily-briefing/reader/reader.js +42 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/context.json +1 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/documents/document-001.md +219 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/media.json +1 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/metadata/documents.json +39 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/records/chatgpt-0001.json +49 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/records/chatgpt-0002.json +49 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/records/chatgpt-0003.json +49 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/records/chatgpt-0004.json +49 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/records/chatgpt-0005.json +49 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/records/chatgpt-0006.json +49 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/records/chatgpt-0007.json +49 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/records/chatgpt-0008.json +49 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/records/chatgpt-0009.json +49 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/records/chatgpt-0010.json +49 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/records/chatgpt-0011.json +49 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/records/chatgpt-0012.json +49 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/records/chatgpt-0013.json +49 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/records/chatgpt-0014.json +49 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/records/chatgpt-0015.json +49 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/records/chatgpt-0016.json +49 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/records/chatgpt-0017.json +49 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/records/chatgpt-0018.json +49 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/records/chatgpt-0019.json +49 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/records/chatgpt-0020.json +49 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/records/chatgpt-0021.json +49 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/records/chatgpt-0022.json +49 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/records/chatgpt-0023.json +49 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/records/chatgpt-0024.json +49 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/records/chatgpt-0025.json +49 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/records/chatgpt-0026.json +49 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/records/chatgpt-0027.json +49 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/records/chatgpt-0028.json +49 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/sessions.json +1 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/timeline.jsonl +28 -0
- package/dist/packages/sdk/examples/daily-briefing/sample/untimed.jsonl +0 -0
- package/dist/packages/sdk/examples/daily-briefing/sample-output.zip +0 -0
- package/dist/packages/sdk/examples/daily-briefing/sample-report.md +51 -0
- package/dist/packages/sdk/examples/daily-briefing/uv.lock +128 -0
- package/dist/packages/sdk/examples/daily-briefing/vendor/marked-LICENSE.md +44 -0
- package/dist/packages/sdk/examples/daily-briefing/vendor/marked.mjs +77 -0
- package/dist/packages/sdk/examples/files.json +126 -0
- package/dist/packages/sdk/examples/message-routing/README.md +23 -0
- package/dist/packages/sdk/examples/message-routing/TASK.md +5 -0
- package/dist/packages/sdk/examples/message-routing/choose-destination.mjs +10 -0
- package/dist/packages/sdk/examples/message-routing/create-ticket.mjs +15 -0
- package/dist/packages/sdk/examples/message-routing/inputs.json +1 -0
- package/dist/packages/sdk/examples/message-routing/message-routing.method +45 -0
- package/dist/packages/sdk/examples/message-routing/result.fixture.json +6 -0
- package/dist/packages/sdk/examples/message-routing/routing.mjs +29 -0
- package/dist/packages/sdk/examples/message-routing/routing.test.mjs +27 -0
- package/dist/packages/sdk/examples/message-routing/ticket.method +88 -0
- package/dist/packages/sdk/examples/message-routing/ticket.test.mjs +64 -0
- package/dist/packages/sdk/examples/notes-summary/README.md +23 -0
- package/dist/packages/sdk/examples/notes-summary/TASK.md +3 -0
- package/dist/packages/sdk/examples/notes-summary/cases/dates-in-decisions/case.json +36 -0
- package/dist/packages/sdk/examples/notes-summary/cases/dates-in-decisions/examples.json +16 -0
- package/dist/packages/sdk/examples/notes-summary/cases/dates-in-decisions/files/0 +10 -0
- package/dist/packages/sdk/examples/notes-summary/cases/dates-in-decisions/recording.json +195 -0
- package/dist/packages/sdk/examples/notes-summary/notes/2026-10-05-standup.md +4 -0
- package/dist/packages/sdk/examples/notes-summary/notes/2026-10-07-planning.md +3 -0
- package/dist/packages/sdk/examples/notes-summary/notes-summary.method +54 -0
- package/dist/packages/sdk/examples/notes-summary/out/.keep +0 -0
- package/dist/packages/sdk/examples/notes-summary/read-notes.mjs +4 -0
- package/dist/packages/sdk/examples/notes-summary/save.mjs +7 -0
- package/dist/packages/sdk/examples/outbound-management/README.md +119 -0
- package/dist/packages/sdk/examples/outbound-management/TASK.md +5 -0
- package/dist/packages/sdk/examples/outbound-management/check_plan.py +16 -0
- package/dist/packages/sdk/examples/outbound-management/check_sources.py +14 -0
- package/dist/packages/sdk/examples/outbound-management/crm.py +153 -0
- package/dist/packages/sdk/examples/outbound-management/crm_test.py +141 -0
- package/dist/packages/sdk/examples/outbound-management/fixtures/README.md +15 -0
- package/dist/packages/sdk/examples/outbound-management/fixtures/crm.json +54 -0
- package/dist/packages/sdk/examples/outbound-management/fixtures/day-1/messages.json +29 -0
- package/dist/packages/sdk/examples/outbound-management/fixtures/day-2/messages.json +38 -0
- package/dist/packages/sdk/examples/outbound-management/fixtures/observations.json +12 -0
- package/dist/packages/sdk/examples/outbound-management/fixtures/plan.json +69 -0
- package/dist/packages/sdk/examples/outbound-management/fixtures/receipt.json +1 -0
- package/dist/packages/sdk/examples/outbound-management/fixtures/state.json +99 -0
- package/dist/packages/sdk/examples/outbound-management/fixtures/tasks.md +16 -0
- package/dist/packages/sdk/examples/outbound-management/inputs.json +4 -0
- package/dist/packages/sdk/examples/outbound-management/outbound.method +277 -0
- package/dist/packages/sdk/examples/outbound-management/read_crm.py +4 -0
- package/dist/packages/sdk/examples/outbound-management/save_day.py +4 -0
- package/dist/packages/sdk/examples/outbound-management/starter/crm.json +11 -0
- package/dist/packages/sdk/examples/social-briefing/README.md +41 -0
- package/dist/packages/sdk/examples/social-briefing/TASK.md +5 -0
- package/dist/packages/sdk/examples/social-briefing/inputs.json +5 -0
- package/dist/packages/sdk/examples/social-briefing/result.fixture.json +14 -0
- package/dist/packages/sdk/examples/social-briefing/social-briefing.method +110 -0
- package/dist/packages/sdk/examples/support-triage/README.md +77 -0
- package/dist/packages/sdk/examples/support-triage/TASK.md +3 -0
- package/dist/packages/sdk/examples/support-triage/cases/double-charge-to-billing/case.json +40 -0
- package/dist/packages/sdk/examples/support-triage/cases/double-charge-to-billing/recording.json +460 -0
- package/dist/packages/sdk/examples/support-triage/fake-helpdesk.mjs +38 -0
- package/dist/packages/sdk/examples/support-triage/helpdesk.mjs +33 -0
- package/dist/packages/sdk/examples/support-triage/inputs.json +1 -0
- package/dist/packages/sdk/examples/support-triage/read-ticket.mjs +8 -0
- package/dist/packages/sdk/examples/support-triage/route.mjs +4 -0
- package/dist/packages/sdk/examples/support-triage/sample-tickets.json +5 -0
- package/dist/packages/sdk/examples/support-triage/set-ticket.mjs +9 -0
- package/dist/packages/sdk/examples/support-triage/support-triage.method +165 -0
- package/dist/packages/sdk/examples/support-triage/triage.mjs +38 -0
- package/dist/packages/sdk/examples/support-triage/triage.test.mjs +68 -0
- package/dist/packages/sdk/observers/mail.delivery/fetch.mjs +87 -0
- package/dist/packages/sdk/observers/mail.delivery/fixtures/bounce-by-recipient.json +1 -0
- package/dist/packages/sdk/observers/mail.delivery/fixtures/bounce.json +1 -0
- package/dist/packages/sdk/observers/mail.delivery/fixtures/delayed.json +1 -0
- package/dist/packages/sdk/observers/mail.delivery/fixtures/delivered.json +1 -0
- package/dist/packages/sdk/observers/mail.delivery/fixtures/empty.json +1 -0
- package/dist/packages/sdk/observers/mail.delivery/fixtures/other-message.json +1 -0
- package/dist/packages/sdk/observers/mail.delivery/judge.mjs +13 -0
- package/dist/packages/sdk/observers/mail.delivery/observer.json +14 -0
- package/dist/packages/sdk/package.json +232 -0
- package/dist/packages/sdk/src/authoring-example.d.ts +91 -0
- package/dist/packages/sdk/src/authoring-example.js +50 -0
- package/dist/packages/sdk/src/authoring-instructions.d.ts +20 -0
- package/dist/packages/sdk/src/authoring-instructions.js +151 -0
- package/dist/packages/sdk/src/authoring-prompt.d.ts +2 -0
- package/dist/packages/sdk/src/authoring-prompt.js +8 -0
- package/dist/packages/sdk/src/authoring.d.ts +10 -0
- package/dist/packages/sdk/src/authoring.js +287 -0
- package/dist/packages/sdk/src/bindings.d.ts +4 -0
- package/dist/packages/sdk/src/bindings.js +124 -0
- package/dist/packages/sdk/src/bridge.d.ts +2 -0
- package/dist/packages/sdk/src/bridge.js +23 -0
- package/dist/packages/sdk/src/browser-runtime/pyproject.toml +5 -0
- package/dist/packages/sdk/src/browser-runtime/tools.json +189 -0
- package/dist/packages/sdk/src/browser-runtime/uv.lock +3894 -0
- package/dist/packages/sdk/src/browser-service.py +127 -0
- package/dist/packages/sdk/src/browser.d.ts +38 -0
- package/dist/packages/sdk/src/browser.js +203 -0
- package/dist/packages/sdk/src/capabilities.d.ts +13 -0
- package/dist/packages/sdk/src/capabilities.js +68 -0
- package/dist/packages/sdk/src/classification-client.d.ts +4 -0
- package/dist/packages/sdk/src/classification-client.js +27 -0
- package/dist/packages/sdk/src/cloud.d.ts +23 -0
- package/dist/packages/sdk/src/cloud.js +43 -0
- package/dist/packages/sdk/src/computer-settings.d.ts +28 -0
- package/dist/packages/sdk/src/computer-settings.js +57 -0
- package/dist/packages/sdk/src/connect.d.ts +47 -0
- package/dist/packages/sdk/src/connect.js +203 -0
- package/dist/packages/sdk/src/current-inspection.d.ts +3 -0
- package/dist/packages/sdk/src/current-inspection.js +146 -0
- package/dist/packages/sdk/src/current-runtime.d.ts +14 -0
- package/dist/packages/sdk/src/current-runtime.js +167 -0
- package/dist/packages/sdk/src/doctor.d.ts +17 -0
- package/dist/packages/sdk/src/doctor.js +68 -0
- package/dist/packages/sdk/src/executable.d.ts +2 -0
- package/dist/packages/sdk/src/executable.js +22 -0
- package/dist/packages/sdk/src/explain-effects.d.ts +32 -0
- package/dist/packages/sdk/src/explain-effects.js +192 -0
- package/dist/packages/sdk/src/explain.d.ts +115 -0
- package/dist/packages/sdk/src/explain.js +441 -0
- package/dist/packages/sdk/src/files.d.ts +9 -0
- package/dist/packages/sdk/src/files.js +50 -0
- package/dist/packages/sdk/src/guard-keys.d.ts +5 -0
- package/dist/packages/sdk/src/guard-keys.js +42 -0
- package/dist/packages/sdk/src/hosted-models.d.ts +49 -0
- package/dist/packages/sdk/src/hosted-models.js +81 -0
- package/dist/packages/sdk/src/improve.d.ts +55 -0
- package/dist/packages/sdk/src/improve.js +159 -0
- package/dist/packages/sdk/src/index.d.ts +13 -0
- package/dist/packages/sdk/src/index.js +13 -0
- package/dist/packages/sdk/src/inspect.d.ts +6 -0
- package/dist/packages/sdk/src/inspect.js +17 -0
- package/dist/packages/sdk/src/link.d.ts +12 -0
- package/dist/packages/sdk/src/link.js +53 -0
- package/dist/packages/sdk/src/local-cli.d.ts +24 -0
- package/dist/packages/sdk/src/local-cli.js +146 -0
- package/dist/packages/sdk/src/local-setup.d.ts +15 -0
- package/dist/packages/sdk/src/local-setup.js +42 -0
- package/dist/packages/sdk/src/method-client.d.ts +30 -0
- package/dist/packages/sdk/src/method-client.js +250 -0
- package/dist/packages/sdk/src/method-files.d.ts +29 -0
- package/dist/packages/sdk/src/method-files.js +93 -0
- package/dist/packages/sdk/src/method-help.d.ts +19 -0
- package/dist/packages/sdk/src/method-help.js +404 -0
- package/dist/packages/sdk/src/method-issues.d.ts +86 -0
- package/dist/packages/sdk/src/method-issues.js +192 -0
- package/dist/packages/sdk/src/method-sync.d.ts +46 -0
- package/dist/packages/sdk/src/method-sync.js +293 -0
- package/dist/packages/sdk/src/method.d.ts +5 -0
- package/dist/packages/sdk/src/method.js +359 -0
- package/dist/packages/sdk/src/model-checks.d.ts +96 -0
- package/dist/packages/sdk/src/model-checks.js +181 -0
- package/dist/packages/sdk/src/outbox.d.ts +120 -0
- package/dist/packages/sdk/src/outbox.js +297 -0
- package/dist/packages/sdk/src/own-model-key.d.ts +22 -0
- package/dist/packages/sdk/src/own-model-key.js +76 -0
- package/dist/packages/sdk/src/prepare.d.ts +24 -0
- package/dist/packages/sdk/src/prepare.js +245 -0
- package/dist/packages/sdk/src/process.d.ts +15 -0
- package/dist/packages/sdk/src/process.js +107 -0
- package/dist/packages/sdk/src/production.d.ts +182 -0
- package/dist/packages/sdk/src/production.js +284 -0
- package/dist/packages/sdk/src/progress.d.ts +12 -0
- package/dist/packages/sdk/src/progress.js +48 -0
- package/dist/packages/sdk/src/proposals.d.ts +73 -0
- package/dist/packages/sdk/src/proposals.js +284 -0
- package/dist/packages/sdk/src/quality.d.ts +82 -0
- package/dist/packages/sdk/src/quality.js +294 -0
- package/dist/packages/sdk/src/result-files.d.ts +5 -0
- package/dist/packages/sdk/src/result-files.js +149 -0
- package/dist/packages/sdk/src/result-transfer.d.ts +21 -0
- package/dist/packages/sdk/src/result-transfer.js +37 -0
- package/dist/packages/sdk/src/run-data.d.ts +11 -0
- package/dist/packages/sdk/src/run-data.js +80 -0
- package/dist/packages/sdk/src/run-method.d.ts +2 -0
- package/dist/packages/sdk/src/run-method.js +2 -0
- package/dist/packages/sdk/src/run-saved.d.ts +5 -0
- package/dist/packages/sdk/src/run-saved.js +138 -0
- package/dist/packages/sdk/src/run-worker.d.ts +6 -0
- package/dist/packages/sdk/src/run-worker.js +131 -0
- package/dist/packages/sdk/src/secrets.d.ts +37 -0
- package/dist/packages/sdk/src/secrets.js +196 -0
- package/dist/packages/sdk/src/update.d.ts +5 -0
- package/dist/packages/sdk/src/update.js +39 -0
- package/dist/packages/sdk/src/versions.d.ts +58 -0
- package/dist/packages/sdk/src/versions.js +144 -0
- package/dist/packages/sdk/src/worker.d.ts +120 -0
- package/dist/packages/sdk/src/worker.js +443 -0
- package/dist/packages/workflow-language/src/execution.d.ts +1 -0
- package/dist/packages/workflow-language/src/execution.js +1 -0
- package/dist/packages/workflow-language/src/inspection.d.ts +990 -0
- package/dist/packages/workflow-language/src/inspection.js +130 -0
- package/dist/packages/workflow-language/src/result-files.d.ts +160 -0
- package/dist/packages/workflow-language/src/result-files.js +67 -0
- package/dist/packages/workflow-language/src/schema.d.ts +207 -0
- package/dist/packages/workflow-language/src/schema.js +56 -0
- package/dist/packages/workflow-language/src/validate.d.ts +11 -0
- package/dist/packages/workflow-language/src/validate.js +77 -0
- package/package.json +230 -4
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
[
|
|
2
|
+
{"id": "T-1042", "subject": "Charged twice this month", "body": "Hi, my card was charged $49 two times on October 3 for the Team plan. We only have one workspace. Please refund the second charge.", "queue": "new", "priority": "normal", "tags": [], "notes": []},
|
|
3
|
+
{"id": "T-1043", "subject": "Can't get in", "body": "Since this morning nothing works for me. I tried twice. Can someone look at it?", "queue": "new", "priority": "normal", "tags": [], "notes": []},
|
|
4
|
+
{"id": "T-1044", "subject": "Export to CSV fails", "body": "When I export the March report to CSV I get the error 'Export failed (code 502)'. Other reports export fine. Our quarterly review is tomorrow morning.", "queue": "new", "priority": "normal", "tags": [], "notes": []}
|
|
5
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import {helpdesk, main, ticketPath} from './helpdesk.mjs';
|
|
2
|
+
import {change} from './triage.mjs';
|
|
3
|
+
|
|
4
|
+
// --team-only: the move after a person's answer. It sets the chosen team's queue and adds no note.
|
|
5
|
+
const teamOnly = process.argv.includes('--team-only');
|
|
6
|
+
await main(async input => {
|
|
7
|
+
const ticket = await helpdesk(input.helpdesk, ticketPath(input.ticket_id), {method: 'PATCH', body: change(input, teamOnly)});
|
|
8
|
+
return {[teamOnly ? 'moved' : 'updated']: {id: ticket.id, queue: ticket.queue, priority: ticket.priority}};
|
|
9
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
format: method/3.4
|
|
2
|
+
name: Triage a support ticket
|
|
3
|
+
goal: Read a new help desk ticket, choose its queue and priority, draft a first reply, and update the ticket. Ask a person when the team is unclear.
|
|
4
|
+
run_data: device
|
|
5
|
+
models:
|
|
6
|
+
writer: anthropic/claude-haiku-4.5
|
|
7
|
+
secrets:
|
|
8
|
+
HELPDESK_TOKEN: API token for the help desk. read-ticket.mjs and set-ticket.mjs send it as a Bearer token.
|
|
9
|
+
inputs:
|
|
10
|
+
ticket_id:
|
|
11
|
+
type: text
|
|
12
|
+
description: ID of the new ticket in the help desk, such as T-1042.
|
|
13
|
+
environment:
|
|
14
|
+
helpdesk:
|
|
15
|
+
type: service
|
|
16
|
+
description: Help desk API. GET /tickets/ID reads a ticket; PATCH /tickets/ID sets its queue, priority, tags, and internal note.
|
|
17
|
+
steps:
|
|
18
|
+
read:
|
|
19
|
+
name: Read the ticket
|
|
20
|
+
purpose: Reads the ticket from the help desk with HELPDESK_TOKEN and returns its id, subject, and text. Fails when the help desk does not return the ticket. Changes nothing.
|
|
21
|
+
in: {ticket_id: inputs.ticket_id, helpdesk: environment.helpdesk}
|
|
22
|
+
do: {kind: run, runtime: node, entrypoint: read-ticket.mjs}
|
|
23
|
+
out:
|
|
24
|
+
ticket: {type: record, fields: {id: text, subject: text, body: text}, description: The ticket as the help desk returned it.}
|
|
25
|
+
|
|
26
|
+
team:
|
|
27
|
+
name: Choose the team
|
|
28
|
+
in: {subject: ticket.subject, body: ticket.body}
|
|
29
|
+
do:
|
|
30
|
+
kind: classify
|
|
31
|
+
question: Which team should handle the main request in this support ticket?
|
|
32
|
+
options:
|
|
33
|
+
billing: Billing - invoices, charges, refunds, and plan changes.
|
|
34
|
+
technical: Technical - errors, outages, and problems using the product.
|
|
35
|
+
account: Account - sign-in, users, and access.
|
|
36
|
+
unclear: Unclear - the ticket does not say enough to choose, or no listed team fits.
|
|
37
|
+
out: team
|
|
38
|
+
|
|
39
|
+
refund:
|
|
40
|
+
name: Check for a refund request
|
|
41
|
+
in: {body: ticket.body}
|
|
42
|
+
do:
|
|
43
|
+
kind: classify
|
|
44
|
+
question: Does the customer ask for money back?
|
|
45
|
+
answer: yes_no
|
|
46
|
+
out: refund
|
|
47
|
+
|
|
48
|
+
urgency:
|
|
49
|
+
name: Score the urgency
|
|
50
|
+
in: {subject: ticket.subject, body: ticket.body}
|
|
51
|
+
do:
|
|
52
|
+
kind: classify
|
|
53
|
+
question: How urgent is this support ticket for the customer?
|
|
54
|
+
levels: [low, normal, high, urgent]
|
|
55
|
+
out: urgency
|
|
56
|
+
|
|
57
|
+
route:
|
|
58
|
+
name: Choose the queue and priority
|
|
59
|
+
purpose: Chooses the team's queue when the team is not unclear and its probability is at least 80%. Otherwise chooses the triage queue and marks the ticket for a person. The priority is the most likely urgency level. Adds the tag refund_request when the probability of a refund request is at least 50%. Returns the rule used. Changes nothing.
|
|
60
|
+
in: {team: team, refund: refund, urgency: urgency}
|
|
61
|
+
do: {kind: run, runtime: node, entrypoint: route.mjs}
|
|
62
|
+
out:
|
|
63
|
+
decision:
|
|
64
|
+
type: record
|
|
65
|
+
description: The queue, priority, and tags, whether a person must choose the team, and the rule used.
|
|
66
|
+
fields:
|
|
67
|
+
queue: text
|
|
68
|
+
priority: text
|
|
69
|
+
tags: {type: list, items: text}
|
|
70
|
+
needs_person: boolean
|
|
71
|
+
rule: text
|
|
72
|
+
reason: text
|
|
73
|
+
|
|
74
|
+
draft_reply:
|
|
75
|
+
name: Draft the first reply
|
|
76
|
+
in: {ticket: ticket}
|
|
77
|
+
do:
|
|
78
|
+
kind: call
|
|
79
|
+
model: writer
|
|
80
|
+
prompt: |
|
|
81
|
+
Write the first reply to this support ticket, for a support agent to check and send.
|
|
82
|
+
Thank the customer in one sentence. Then say in one or two sentences what happens next.
|
|
83
|
+
If the ticket does not say what is wrong, ask for the one detail that is missing.
|
|
84
|
+
Do not promise a refund, a date, or a fix. Sign it "The support team".
|
|
85
|
+
|
|
86
|
+
Subject: {{ticket.subject}}
|
|
87
|
+
{{ticket.body}}
|
|
88
|
+
|
|
89
|
+
Two replies that the team liked:
|
|
90
|
+
|
|
91
|
+
Ticket: "I was charged twice for October."
|
|
92
|
+
Reply: "Thank you for telling us about the double charge. Our billing team will check your October invoices and reply within one business day. The support team"
|
|
93
|
+
|
|
94
|
+
Ticket: "The export button does nothing."
|
|
95
|
+
Reply: "Thank you for the report. Which browser do you use, and does the export fail for every report or only for one? With that, our technical team can look into it. The support team"
|
|
96
|
+
out:
|
|
97
|
+
reply: {type: text, description: The reply draft for a support agent to check.}
|
|
98
|
+
|
|
99
|
+
update:
|
|
100
|
+
name: Update the ticket
|
|
101
|
+
purpose: Sets the ticket's queue, priority, and tags, and adds the reply draft as an internal note, with one PATCH request. A repeated request sets the same values, so a retry is safe. A ticket whose team is unclear goes to the triage queue.
|
|
102
|
+
in:
|
|
103
|
+
ticket_id: inputs.ticket_id
|
|
104
|
+
queue: decision.queue
|
|
105
|
+
priority: decision.priority
|
|
106
|
+
tags: decision.tags
|
|
107
|
+
note: reply
|
|
108
|
+
helpdesk: environment.helpdesk
|
|
109
|
+
do: {kind: run, runtime: node, entrypoint: set-ticket.mjs}
|
|
110
|
+
changes: [environment.helpdesk]
|
|
111
|
+
effects:
|
|
112
|
+
updated:
|
|
113
|
+
intent: The ticket has the chosen priority. The queue is not part of this effect, because the move step changes it after a person's answer.
|
|
114
|
+
in: {ticket_id: inputs.ticket_id, priority: decision.priority}
|
|
115
|
+
observe:
|
|
116
|
+
kind: http
|
|
117
|
+
path: /tickets/{inputs.ticket_id}
|
|
118
|
+
expect: {fields: {priority: "{inputs.priority}"}}
|
|
119
|
+
out:
|
|
120
|
+
updated: {type: record, fields: {id: text, queue: text, priority: text}, description: The queue and priority that the help desk returned.}
|
|
121
|
+
|
|
122
|
+
ask_person:
|
|
123
|
+
name: Ask a person for the team
|
|
124
|
+
when: decision.needs_person
|
|
125
|
+
after: update
|
|
126
|
+
in: {ticket: ticket, reason: decision.reason}
|
|
127
|
+
ask: |
|
|
128
|
+
Ticket {{ticket.id}} is in the triage queue: {{reason}}.
|
|
129
|
+
Subject: {{ticket.subject}}
|
|
130
|
+
|
|
131
|
+
Which team should handle it: billing, technical, or account?
|
|
132
|
+
out:
|
|
133
|
+
chosen_queue: {type: text, description: "billing, technical, or account"}
|
|
134
|
+
|
|
135
|
+
move:
|
|
136
|
+
name: Move the ticket to the chosen team
|
|
137
|
+
when: decision.needs_person
|
|
138
|
+
purpose: Rejects an answer that is not billing, technical, or account. Then sets that queue with one PATCH request and keeps the priority and tags. A repeated request sets the same values.
|
|
139
|
+
in:
|
|
140
|
+
ticket_id: inputs.ticket_id
|
|
141
|
+
queue: chosen_queue
|
|
142
|
+
priority: decision.priority
|
|
143
|
+
tags: decision.tags
|
|
144
|
+
helpdesk: environment.helpdesk
|
|
145
|
+
do: {kind: run, runtime: node, entrypoint: set-ticket.mjs, args: [--team-only]}
|
|
146
|
+
changes: [environment.helpdesk]
|
|
147
|
+
effects:
|
|
148
|
+
moved:
|
|
149
|
+
intent: The ticket is in the queue that the person chose.
|
|
150
|
+
in: {ticket_id: inputs.ticket_id, queue: chosen_queue}
|
|
151
|
+
observe:
|
|
152
|
+
kind: http
|
|
153
|
+
path: /tickets/{inputs.ticket_id}
|
|
154
|
+
expect: {fields: {queue: "{inputs.queue}"}}
|
|
155
|
+
out:
|
|
156
|
+
moved: {type: record, fields: {id: text, queue: text, priority: text}, description: The queue and priority that the help desk returned.}
|
|
157
|
+
accept:
|
|
158
|
+
unused_output: The receipt stays in the run record. The result cannot name it, because the step is skipped when the team is clear.
|
|
159
|
+
result:
|
|
160
|
+
decision: decision
|
|
161
|
+
reply: reply
|
|
162
|
+
ticket: updated
|
|
163
|
+
files:
|
|
164
|
+
- helpdesk.mjs
|
|
165
|
+
- triage.mjs
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The rules of support-triage.method: the routing rule and the ticket change. No requests; the tests import them.
|
|
2
|
+
export const TEAM_THRESHOLD = 0.8;
|
|
3
|
+
export const REFUND_THRESHOLD = 0.5;
|
|
4
|
+
export const TEAMS = ['billing', 'technical', 'account'];
|
|
5
|
+
export const PRIORITIES = ['low', 'normal', 'high', 'urgent'];
|
|
6
|
+
|
|
7
|
+
const probability = value => Number.isFinite(value) && value >= 0 && value <= 1;
|
|
8
|
+
|
|
9
|
+
/** Chooses the queue, priority, and tags from the three classifier results. */
|
|
10
|
+
export function route(team, refund, urgency) {
|
|
11
|
+
const options = [...TEAMS, 'unclear'];
|
|
12
|
+
if (!options.includes(team?.choice) || !options.every(id => probability(team.probabilities?.[id])))
|
|
13
|
+
throw Error('Expected a team choice with one probability per option.');
|
|
14
|
+
if (typeof refund?.answer !== 'boolean' || !probability(refund.probability))
|
|
15
|
+
throw Error('Expected a yes or no (true or false) refund answer with the probability of yes.');
|
|
16
|
+
if (!PRIORITIES.includes(urgency?.level)) throw Error('Expected an urgency level.');
|
|
17
|
+
|
|
18
|
+
const chosen = team.probabilities[team.choice];
|
|
19
|
+
const base = {priority: urgency.level, tags: refund.probability >= REFUND_THRESHOLD ? ['refund_request'] : []};
|
|
20
|
+
const percent = `${Math.round(chosen * 100)}% probability`;
|
|
21
|
+
if (team.choice === 'unclear')
|
|
22
|
+
return {...base, queue: 'triage', needs_person: true, rule: 'team_unclear', reason: 'the classifier could not choose a team'};
|
|
23
|
+
if (chosen < TEAM_THRESHOLD)
|
|
24
|
+
return {...base, queue: 'triage', needs_person: true, rule: 'team_below_threshold', reason: `the classifier chose ${team.choice} with only ${percent}`};
|
|
25
|
+
return {...base, queue: team.choice, needs_person: false, rule: 'team_meets_threshold', reason: `the classifier chose ${team.choice} with ${percent}`};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The PATCH body. After a person's answer (teamOnly), the queue must be a team and no note is added. */
|
|
29
|
+
export function change({queue, priority, tags, note}, teamOnly = false) {
|
|
30
|
+
const queues = teamOnly ? TEAMS : [...TEAMS, 'triage'];
|
|
31
|
+
const target = teamOnly ? String(queue ?? '').trim().toLowerCase() : queue;
|
|
32
|
+
if (!queues.includes(target)) throw Error(`The queue must be one of ${queues.join(', ')}, not "${queue}".`);
|
|
33
|
+
if (!PRIORITIES.includes(priority)) throw Error(`Unknown priority "${priority}".`);
|
|
34
|
+
if (!Array.isArray(tags) || tags.some(tag => typeof tag !== 'string')) throw Error('Expected a list of tags.');
|
|
35
|
+
if (teamOnly) return {queue: target, priority, tags};
|
|
36
|
+
if (typeof note !== 'string') throw Error('Expected the reply draft as the note.');
|
|
37
|
+
return {queue: target, priority, tags, internal_note: note};
|
|
38
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {execFile} from 'node:child_process';
|
|
4
|
+
import {readFileSync} from 'node:fs';
|
|
5
|
+
import {promisify} from 'node:util';
|
|
6
|
+
import {fileURLToPath} from 'node:url';
|
|
7
|
+
import {route, change} from './triage.mjs';
|
|
8
|
+
import {startHelpdesk} from './fake-helpdesk.mjs';
|
|
9
|
+
|
|
10
|
+
const team = (choice, p) => ({choice, probabilities: {billing: 0, technical: 0, account: 0, unclear: 0, [choice]: p}});
|
|
11
|
+
const no = {answer: false, probability: 0.1};
|
|
12
|
+
const normal = {level: 'normal', score: 1, probabilities: {low: 0, normal: 1, high: 0, urgent: 0}};
|
|
13
|
+
|
|
14
|
+
test('a clear team at the threshold gets its own queue', () => {
|
|
15
|
+
const decision = route(team('billing', 0.8), no, normal);
|
|
16
|
+
assert.deepEqual(decision, {priority: 'normal', tags: [], queue: 'billing', needs_person: false, rule: 'team_meets_threshold', reason: 'the classifier chose billing with 80% probability'});
|
|
17
|
+
});
|
|
18
|
+
test('a team below the threshold goes to triage for a person', () => {
|
|
19
|
+
const decision = route(team('technical', 0.79), no, normal);
|
|
20
|
+
assert.equal(decision.queue, 'triage');
|
|
21
|
+
assert.equal(decision.needs_person, true);
|
|
22
|
+
assert.equal(decision.rule, 'team_below_threshold');
|
|
23
|
+
});
|
|
24
|
+
test('unclear always goes to triage, whatever its probability', () => {
|
|
25
|
+
const decision = route(team('unclear', 1), no, normal);
|
|
26
|
+
assert.equal(decision.rule, 'team_unclear');
|
|
27
|
+
assert.equal(decision.needs_person, true);
|
|
28
|
+
});
|
|
29
|
+
test('a refund probability of at least 50% adds the tag, and urgency sets the priority', () => {
|
|
30
|
+
const decision = route(team('billing', 0.95), {answer: true, probability: 0.5}, {...normal, level: 'urgent'});
|
|
31
|
+
assert.deepEqual(decision.tags, ['refund_request']);
|
|
32
|
+
assert.equal(decision.priority, 'urgent');
|
|
33
|
+
assert.deepEqual(route(team('billing', 0.95), {answer: false, probability: 0.49}, normal).tags, []);
|
|
34
|
+
});
|
|
35
|
+
test('invalid classifier results are refused', () => {
|
|
36
|
+
assert.throws(() => route({choice: 'sales', probabilities: {}}, no, normal));
|
|
37
|
+
assert.throws(() => route(team('billing', 0.9), {answer: 'yes', probability: 0.5}, normal));
|
|
38
|
+
assert.throws(() => route(team('billing', 0.9), no, {level: 'critical'}));
|
|
39
|
+
});
|
|
40
|
+
test('the move after a person accepts only a team and adds no note', () => {
|
|
41
|
+
assert.deepEqual(change({queue: ' Account ', priority: 'high', tags: []}, true), {queue: 'account', priority: 'high', tags: []});
|
|
42
|
+
assert.throws(() => change({queue: 'triage', priority: 'high', tags: []}, true), /must be one of billing, technical, account/);
|
|
43
|
+
assert.throws(() => change({queue: 'billing', priority: 'high', tags: []}), /reply draft/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const run = promisify(execFile);
|
|
47
|
+
const script = (name, input, args = [], token = 'test-token') => {
|
|
48
|
+
const child = run(process.execPath, [fileURLToPath(new URL(name, import.meta.url)), ...args], {env: {...process.env, HELPDESK_TOKEN: token}});
|
|
49
|
+
child.child.stdin.end(JSON.stringify(input));
|
|
50
|
+
return child.then(({stdout}) => JSON.parse(stdout));
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
test('the scripts read and change a ticket with the token, and a repeat sets the same values', async t => {
|
|
54
|
+
const tickets = JSON.parse(readFileSync(new URL('./sample-tickets.json', import.meta.url), 'utf8'));
|
|
55
|
+
const desk = await startHelpdesk({token: 'test-token', tickets});
|
|
56
|
+
t.after(() => desk.close());
|
|
57
|
+
const read = await script('read-ticket.mjs', {ticket_id: 'T-1042', helpdesk: desk.url});
|
|
58
|
+
assert.equal(read.ticket.subject, 'Charged twice this month');
|
|
59
|
+
const input = {ticket_id: 'T-1042', helpdesk: desk.url, queue: 'billing', priority: 'high', tags: ['refund_request'], note: 'Draft reply'};
|
|
60
|
+
const first = await script('set-ticket.mjs', input), second = await script('set-ticket.mjs', input);
|
|
61
|
+
assert.deepEqual(first, {updated: {id: 'T-1042', queue: 'billing', priority: 'high'}});
|
|
62
|
+
assert.deepEqual(second, first);
|
|
63
|
+
assert.deepEqual(desk.store.get('T-1042').notes, ['Draft reply']);
|
|
64
|
+
const moved = await script('set-ticket.mjs', {...input, queue: 'account'}, ['--team-only']);
|
|
65
|
+
assert.deepEqual(moved, {moved: {id: 'T-1042', queue: 'account', priority: 'high'}});
|
|
66
|
+
await assert.rejects(script('set-ticket.mjs', input, [], 'wrong-token'), /401/);
|
|
67
|
+
await assert.rejects(script('read-ticket.mjs', {ticket_id: 'T-9999', helpdesk: desk.url}), /404/);
|
|
68
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Read delivery reports about one message from an IMAP mailbox. Read-only: EXAMINE and BODY.PEEK.
|
|
2
|
+
import tls from 'node:tls';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
|
|
6
|
+
const input = JSON.parse(readFileSync(0, 'utf8'));
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
const name = args[args.indexOf('--connection') + 1];
|
|
9
|
+
const connections = JSON.parse(process.env.METHOD_ENVIRONMENT ?? '{}');
|
|
10
|
+
if (!name || !connections[name]) throw Error(`Observer connection ${name} is not configured`);
|
|
11
|
+
const url = new URL(connections[name]);
|
|
12
|
+
const local = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
13
|
+
if (url.protocol !== 'imaps:' && !(url.protocol === 'imap:' && local)) throw Error('Use an imaps:// mailbox URL');
|
|
14
|
+
const password = process.env.MAIL_OBSERVER_PASSWORD;
|
|
15
|
+
if (!password) throw Error('MAIL_OBSERVER_PASSWORD is not set');
|
|
16
|
+
const mailbox = decodeURIComponent(url.pathname.slice(1) || 'INBOX');
|
|
17
|
+
const port = Number(url.port || (url.protocol === 'imaps:' ? 993 : 143));
|
|
18
|
+
const socket = url.protocol === 'imaps:' ? tls.connect({ host: url.hostname, port, servername: url.hostname }) : net.connect({ host: url.hostname, port });
|
|
19
|
+
socket.setTimeout(30_000, () => socket.destroy(Error('IMAP timeout')));
|
|
20
|
+
|
|
21
|
+
// A small reader for IMAP lines and {n} literals.
|
|
22
|
+
let buffer = Buffer.alloc(0), waiting = null, failure;
|
|
23
|
+
socket.on('data', chunk => { buffer = Buffer.concat([buffer, chunk]); waiting?.(); });
|
|
24
|
+
socket.on('error', error => { failure = error; waiting?.(); });
|
|
25
|
+
socket.on('close', () => { failure ??= Error('IMAP connection closed'); waiting?.(); });
|
|
26
|
+
const more = () => new Promise((resolve, reject) => {
|
|
27
|
+
if (failure) return reject(failure);
|
|
28
|
+
waiting = () => { waiting = null; if (buffer.length) resolve(); else reject(failure); };
|
|
29
|
+
});
|
|
30
|
+
async function line() {
|
|
31
|
+
for (;;) {
|
|
32
|
+
const end = buffer.indexOf('\r\n');
|
|
33
|
+
if (end >= 0) {
|
|
34
|
+
let text = buffer.subarray(0, end).toString('utf8'); buffer = buffer.subarray(end + 2);
|
|
35
|
+
const literal = /\{(\d+)\}$/.exec(text);
|
|
36
|
+
if (literal) {
|
|
37
|
+
const size = Number(literal[1]);
|
|
38
|
+
while (buffer.length < size) await more();
|
|
39
|
+
text += '\n' + buffer.subarray(0, size).toString('utf8'); buffer = buffer.subarray(size);
|
|
40
|
+
text += await line();
|
|
41
|
+
}
|
|
42
|
+
return text;
|
|
43
|
+
}
|
|
44
|
+
await more();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
let tag = 0;
|
|
48
|
+
async function command(text) {
|
|
49
|
+
const id = `m${++tag}`;
|
|
50
|
+
socket.write(`${id} ${text}\r\n`);
|
|
51
|
+
const untagged = [];
|
|
52
|
+
for (;;) {
|
|
53
|
+
const response = await line();
|
|
54
|
+
if (response.startsWith(`${id} `)) {
|
|
55
|
+
if (!/^\S+ OK/i.test(response)) throw Error(`IMAP ${text.split(' ')[0]} failed: ${response.slice(id.length + 1, 200)}`);
|
|
56
|
+
return untagged;
|
|
57
|
+
}
|
|
58
|
+
untagged.push(response);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const quote = value => '"' + String(value).replace(/["\\]/g, m => '\\' + m) + '"';
|
|
62
|
+
const since = new Date(Date.now() - 7 * 86_400_000);
|
|
63
|
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
64
|
+
const sinceText = `${since.getUTCDate()}-${months[since.getUTCMonth()]}-${since.getUTCFullYear()}`;
|
|
65
|
+
|
|
66
|
+
const greeting = await line();
|
|
67
|
+
if (!/^\* (OK|PREAUTH)/i.test(greeting)) throw Error('Unexpected IMAP greeting');
|
|
68
|
+
await command(`LOGIN ${quote(decodeURIComponent(url.username))} ${quote(password)}`);
|
|
69
|
+
await command(`EXAMINE ${quote(mailbox)}`);
|
|
70
|
+
const search = await command(`UID SEARCH SINCE ${sinceText} OR TEXT ${quote(input.token)} OR FROM "mailer-daemon" FROM "postmaster"`);
|
|
71
|
+
const uids = search.flatMap(text => /^\* SEARCH/i.test(text) ? text.slice(8).trim().split(/\s+/).filter(Boolean) : []).slice(-50);
|
|
72
|
+
const recipient = String(input.inputs?.to ?? '').toLowerCase();
|
|
73
|
+
const observations = [];
|
|
74
|
+
for (const uid of uids) {
|
|
75
|
+
const parts = (await command(`UID FETCH ${uid} (BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)] BODY.PEEK[TEXT]<0.65536>)`)).join('\n');
|
|
76
|
+
const header = key => (new RegExp(`^${key}:\\s*(.*)$`, 'im').exec(parts)?.[1] ?? '').trim();
|
|
77
|
+
const lower = parts.toLowerCase();
|
|
78
|
+
// Keep only what the judge needs; never the message text.
|
|
79
|
+
observations.push({ source: 'imap', ref: `${mailbox}/uid:${uid}`, data: {
|
|
80
|
+
from: header('From'), subject: header('Subject'), date: header('Date'),
|
|
81
|
+
action: (/^action:\s*(\w+)/im.exec(parts)?.[1] ?? '').toLowerCase(), status: /^status:\s*([245]\.\d{1,3}\.\d{1,3})/im.exec(parts)?.[1] ?? '',
|
|
82
|
+
mentions_token: lower.includes(String(input.token).toLowerCase()), mentions_recipient: !!recipient && lower.includes(recipient),
|
|
83
|
+
} });
|
|
84
|
+
}
|
|
85
|
+
await command('LOGOUT').catch(() => {});
|
|
86
|
+
socket.end();
|
|
87
|
+
console.log(JSON.stringify({ observations }));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"inputs":{"to":"dana@vendor.example"},"observations":[{"source":"imap","ref":"INBOX/uid:8","data":{"from":"postmaster@vendor.example","subject":"Undeliverable: Morning summary","date":"","action":"","status":"","mentions_token":false,"mentions_recipient":true}}],"expect":"contradicted"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"inputs":{"to":"dana@vendor.example"},"observations":[{"source":"imap","ref":"INBOX/uid:7","data":{"from":"Mail Delivery Subsystem <mailer-daemon@googlemail.com>","subject":"Delivery Status Notification (Failure)","date":"","action":"failed","status":"5.1.1","mentions_token":true,"mentions_recipient":true}}],"expect":"contradicted"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"inputs":{"to":"dana@vendor.example"},"observations":[{"source":"imap","ref":"INBOX/uid:9","data":{"from":"Mail Delivery Subsystem <mailer-daemon@googlemail.com>","subject":"Delivery Status Notification (Delay)","date":"","action":"delayed","status":"4.4.7","mentions_token":true,"mentions_recipient":true}}],"expect":"no_evidence"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"inputs":{"to":"dana@vendor.example"},"observations":[{"source":"imap","ref":"INBOX/uid:11","data":{"from":"postmaster@sender.example","subject":"Delivery Status Notification (Success)","date":"","action":"delivered","status":"2.0.0","mentions_token":true,"mentions_recipient":true}}],"expect":"confirmed"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"inputs":{"to":"dana@vendor.example"},"observations":[],"expect":"no_evidence"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"inputs":{"to":"dana@vendor.example"},"observations":[{"source":"imap","ref":"INBOX/uid:10","data":{"from":"Mail Delivery Subsystem <mailer-daemon@googlemail.com>","subject":"Delivery Status Notification (Failure)","date":"","action":"failed","status":"5.1.1","mentions_token":false,"mentions_recipient":false}}],"expect":"no_evidence"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Decide what the delivery reports show. Deterministic: no network, no model.
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
const { token, observations } = JSON.parse(readFileSync(0, 'utf8'));
|
|
4
|
+
const daemon = /mailer-daemon|postmaster|mail delivery (sub)?system/i;
|
|
5
|
+
const failedSubject = /undeliver|delivery status notification \(failure\)|mail delivery failed|returned mail|delivery has failed|could not be delivered|couldn't be delivered|not delivered|delivery failure/i;
|
|
6
|
+
// A report is about this message when it names its token, or names the recipient and comes from a mail system.
|
|
7
|
+
const relevant = observations.filter(o => o.data?.mentions_token || (o.data?.mentions_recipient && daemon.test(o.data?.from ?? '')));
|
|
8
|
+
const failed = relevant.find(o => o.data.action === 'failed' || /^5\./.test(o.data.status) || (o.data.action !== 'delayed' && !/^4\./.test(o.data.status) && failedSubject.test(o.data.subject ?? '')));
|
|
9
|
+
const delivered = relevant.find(o => o.data.action === 'delivered' && o.data.mentions_token);
|
|
10
|
+
const out = failed ? { verdict: 'contradicted', reason: `Delivery failed${failed.data.status ? ` (${failed.data.status})` : ''}: ${failed.data.subject}`, evidence: [failed.ref] }
|
|
11
|
+
: delivered ? { verdict: 'confirmed', reason: 'A delivery report names this message as delivered.', evidence: [delivered.ref] }
|
|
12
|
+
: { verdict: 'no_evidence', reason: relevant.length ? `Only delay reports so far (${relevant.length}).` : `No delivery report names ${token} or the recipient.`, evidence: relevant.map(o => o.ref) };
|
|
13
|
+
console.log(JSON.stringify(out));
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Searches the mailbox that receives bounces for delivery reports about one sent message. Uses IMAP EXAMINE and BODY.PEEK, so it does not change the mailbox or mark mail as read.",
|
|
3
|
+
"connection": { "name": "bounce_mailbox", "type": "service", "description": "IMAP mailbox that receives delivery reports for the sending address, as imaps://USER@HOST:993/INBOX. Used only to read." },
|
|
4
|
+
"needs": "A runtime profile mail_observer whose env lists MAIL_OBSERVER_PASSWORD (an app password for the bounce mailbox), and the bounce_mailbox connection URL in runtime.json.",
|
|
5
|
+
"inputs": [{ "alias": "to", "description": "The recipient address of the sent message, for example inputs.ap_lead." }],
|
|
6
|
+
"effect": {
|
|
7
|
+
"intent": "The recipient's mail server accepts the message and does not return it.",
|
|
8
|
+
"observe_runtime": "mail_observer",
|
|
9
|
+
"args": ["--connection", "{connection}"],
|
|
10
|
+
"schedule": { "first": "2m", "then": ["15m", "1h", "6h", "1d", "2d"], "horizon": "5d" },
|
|
11
|
+
"confirm": "unrefuted_at_horizon"
|
|
12
|
+
},
|
|
13
|
+
"setup": "In runtime.json, add runtimes.mail_observer = {\"command\": \"node\", \"version\": \"22\", \"env\": [\"MAIL_OBSERVER_PASSWORD\"]} and environment.bounce_mailbox = \"imaps://USER@HOST:993/INBOX\". Set MAIL_OBSERVER_PASSWORD wherever method run and method observe run. Use an app password with read access only where the provider allows it. The send step must put METHOD_OPERATION_ID in the message, for example Message-ID: <TOKEN@your-domain>; without it the observer can match bounces only by recipient address. Schedule method observe --pending, for example hourly, so bounces that arrive after the run reach it."
|
|
14
|
+
}
|