update on the agents and registry side

This commit is contained in:
2026-09-30 12:03:13 +05:30
parent 92e8da2842
commit 53199127ba
73 changed files with 7268 additions and 1134 deletions

View File

@@ -0,0 +1,92 @@
// ==============================|| Doormile AI — Autonomous Agent Factory ||============================== //
import { SkillRegistry } from '../skills/SkillRegistry';
import { wallClockNow } from './signals';
const SEVERITY_RANK = { critical: 0, warning: 1, watch: 2 };
export class AgentFactory {
/**
* Compiles an Agent configuration from a collection of skills.
*/
static createAgent(skills = SkillRegistry.getActiveSkills()) {
const activeSkills = Array.isArray(skills) ? skills : [];
// 1. Synthesize System Prompt
const instructions = activeSkills
.map((s) => `### ${s.name} (${s.category})\n${(s.instructions || '').trim()}`)
.join('\n\n');
const systemPrompt = `
You are the Doormile Autonomous Logistics Fleet Orchestrator.
You operate with the following active operational skill modules:
${instructions || 'Standard logistics routing and telemetry diagnostics.'}
Guiding Invariant:
Read-only queries execute instantly. Mutating actions (reassignments, pings, OTP enforcement) MUST return human-in-the-loop proposals with clear blast radius.
`.trim();
// 2. Aggregate Tools from active skills
const tools = activeSkills.flatMap((s) => s.tools || []);
const toolMap = new Map();
tools.forEach((t) => toolMap.set(t.name, t));
// 3. Telemetry Evaluation Engine
const evaluateTelemetry = (rows = [], now = wallClockNow()) => {
const allFindings = [];
for (const skill of activeSkills) {
if (typeof skill.evaluate === 'function') {
// Extract current threshold values
const rawThresholds = {};
if (skill.effectiveThresholds) {
Object.entries(skill.effectiveThresholds).forEach(([k, v]) => {
rawThresholds[k] = v.currentValue;
});
} else if (skill.thresholds) {
Object.entries(skill.thresholds).forEach(([k, v]) => {
rawThresholds[k] = v.value;
});
}
const findings = skill.evaluate(rows, now, rawThresholds);
if (Array.isArray(findings)) {
allFindings.push(...findings);
}
}
}
// Sort by severity (critical > warning > watch), then by affected row count descending
return allFindings.sort(
(a, b) =>
(SEVERITY_RANK[a.severity] ?? 99) - (SEVERITY_RANK[b.severity] ?? 99) ||
(b.count || 0) - (a.count || 0)
);
};
return {
name: 'Doormile Fleet Orchestrator',
version: '2.0.0',
activeSkillCount: activeSkills.length,
skills: activeSkills,
tools,
toolMap,
systemPrompt,
evaluateTelemetry,
executeSkillTool: async (toolName, args) => {
const tool = toolMap.get(toolName);
if (!tool || typeof tool.handler !== 'function') {
return { success: false, error: `Skill tool "${toolName}" not found or has no handler.` };
}
return tool.handler(args);
}
};
}
/**
* Helper to build the default active agent runtime based on current SkillRegistry state.
*/
static synthesizeDefaultAgent() {
return AgentFactory.createAgent(SkillRegistry.getActiveSkills());
}
}

View File

@@ -0,0 +1,153 @@
// ==============================|| Doormile AI — proposal executors ||============================== //
//
// The layer that turns a finding's proposal into a real API call.
//
// It exists as its own module because the skills deliberately have no API
// access at all — not one of them imports an endpoint, and their tool handlers
// only build proposal objects. That separation is worth keeping: a skill stays
// a pure function of rows and thresholds, which is why all eight are testable
// without a network. Execution is the part with consequences, so it lives in
// one place where the failure modes can be handled once.
//
// Two rules, both learned the hard way in assignActions.js:
//
// • Never report an action that did not happen. A card that says "rider
// notified" when the push returned 400 is worse than one that says nothing:
// a dispatcher who believes a rider was pinged does not follow up.
// • A partial success is a partial success. Notifying six riders out of eight
// is reported as six out of eight, with the two failures named.
import { getMilers, buildMilerLookup, notifyRider } from '@/api/doormile';
/** A stable, human-facing reference for a row. */
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
/**
* Rider pushes key off milerprofileid; findings carry mileruserid.
*
* These are two different small integers on the same record, and using the
* wrong one fails silently in both directions — the notify 404s, or a different
* rider is paged. buildMilerLookup is the same bridge the Orders page uses;
* this must never grow a second one.
*/
const profileIdFor = (row, lookup) => lookup.byUserId.get(String(row?.userid))?.milerprofileid;
// ---- notifyRider ------------------------------------------------------------
const executeNotifyRider = async (scope, message) => {
const rows = (scope || []).filter((r) => r?.userid);
if (!rows.length) {
return { ok: false, message: 'None of these parcels has an assigned rider to notify.', sourceCalls: [] };
}
// One fetch for the whole batch. Per row would be an identical request each
// time, and the lookup is the same for all of them.
const milers = await getMilers();
const lookup = buildMilerLookup(milers);
const sourceCalls = [];
const notified = [];
const unreachable = [];
for (const row of rows) {
const profileId = profileIdFor(row, lookup);
// No profile id means no push is possible. Recorded rather than skipped,
// so the operator is not left assuming a phone buzzed.
if (!profileId) {
unreachable.push(`${ref(row)} (${row.ridername || 'rider'}: no device registered)`);
continue;
}
try {
// eslint-disable-next-line no-await-in-loop
await notifyRider(profileId, 'Doormile Ops', message);
notified.push(ref(row));
sourceCalls.push({
name: 'notifyRider',
target: `POST /admin/milers/${profileId}/notify`,
status: 'complete',
stats: `${ref(row)} → ${row.ridername || 'rider'}`
});
} catch (err) {
unreachable.push(`${ref(row)} (${err?.message || 'push failed'})`);
sourceCalls.push({
name: 'notifyRider',
target: `POST /admin/milers/${profileId}/notify`,
status: 'error',
errorMessage: err?.message || 'notification failed'
});
}
}
const parts = [];
if (notified.length) parts.push(`Notified ${notified.length} rider${notified.length === 1 ? '' : 's'}.`);
if (unreachable.length) parts.push(`Could not reach ${unreachable.length}: ${unreachable.join('; ')}.`);
return {
ok: notified.length > 0,
message: parts.join(' ') || 'Nothing was sent.',
notified,
unreachable,
sourceCalls
};
};
// ---- assignMiler: deliberately NOT an executor --------------------------------
//
// On the branch this called batchAssignBookings → POST /hub/bookings/batch-assign.
// That route sits behind middlewares.HubStaffAuth, which refuses every token
// whose role is not 6 (hub staff) — so from this console, where every login is
// admin/manager/executive, it would 403 on every click. Shipping it would have
// put a button on the Exceptions banner that could never succeed.
//
// Findings that propose `assignMiler` therefore render "Review only". The
// admin route that could back it, POST /admin/bookings/:id/assign-miler, needs
// a chosen rider per booking, which a finding does not pick. Wiring this is a
// backend decision (an admin batch-assign route), not a console one.
// ---- registry ---------------------------------------------------------------
/**
* Keyed on the `tool` a finding's proposal actually carries.
*
* Deliberately NOT the skills' declared tool names (ping_stalled_rider,
* reassign_sla_critical_order, …). Those belong to a separate, currently unused
* layer whose handlers only build proposals; the UI renders `proposal.tool`,
* and that is what an operator's click has to resolve.
*
* Verbs with no entry here have no endpoint behind them yet. That is not an
* error — the card renders them disabled and says "Review only" rather than
* pretending.
*/
const EXECUTORS = {
notifyRider: (finding) =>
executeNotifyRider(
finding?.proposal?.scope,
finding?.severity === 'critical'
? 'Urgent: this delivery needs attention now. Please check the app.'
: 'Please check this delivery in the app when you can.'
)
// assignMiler: none — see the note above. Its findings render "Review only".
};
/** Whether a finding's proposal can actually be carried out. */
export const canExecuteProposal = (finding) => Boolean(EXECUTORS[finding?.proposal?.tool]);
/** Every verb that currently has a real executor behind it. */
export const executableTools = () => Object.keys(EXECUTORS);
/**
* Runs a finding's proposal.
*
* Throws when there is no executor rather than resolving quietly, so a caller
* cannot mistake "nothing happened" for success. The UI is expected to have
* checked canExecuteProposal first and disabled the control.
*/
export const executeProposal = async (finding) => {
const run = EXECUTORS[finding?.proposal?.tool];
if (!run) {
throw new Error(`No executor for "${finding?.proposal?.tool}" — this proposal is review-only.`);
}
return run(finding);
};

View File

@@ -0,0 +1,112 @@
// ==============================|| Doormile AI — the ops briefing ||============================== //
//
// Turns the findings from signals.js into the shape the assistant panel already
// renders: a headline, a detail body, and a metric. Pure — it takes rows and an
// instant and returns an object, so the whole briefing can be tested without a
// network, a clock, or React.
//
// This is the agent's "explain" step. Every line it produces is traceable to a
// rule in signals.js and to the rows that rule matched, because an operator who
// cannot see why the agent believes something will not act on it — and an
// unexplained instruction from a bot is exactly the thing CLAUDE.md §3 refuses
// to ship.
import { AgentFactory } from './AgentFactory';
import { ALL_CLEAR, wallClockNow } from './signals';
import { toAgentRows } from './normalise';
const SEVERITY_LABEL = {
critical: 'Needs attention now',
warning: 'Worth looking at',
watch: 'Keep an eye on'
};
/**
* A truncated scan is a floor, not a total.
*
* `/admin/bookings` caps pagesize, so a scan drains pages up to a budget and
* reports whether it ran out. Every count built on a truncated scan says "at
* least", because the alternative — stating a total that is quietly a subset —
* is the exact failure the assistant's verifiability contract exists to
* prevent. A missed breach is worse than an approximate one, so the briefing
* still runs; it just stops claiming completeness.
*/
export const countPrefix = (truncated) => (truncated ? 'at least ' : '');
/** One finding rendered as a block of lines. */
const renderFinding = (finding, truncated) => {
const lines = [];
lines.push(`${SEVERITY_LABEL[finding.severity]} — ${finding.title}`);
lines.push(finding.why);
if (finding.detail?.length) {
lines.push(...finding.detail.map((d) => ` • ${d}`));
const shown = finding.detail.length;
if (finding.count > shown) {
lines.push(` • …and ${countPrefix(truncated)}${finding.count - shown} more`);
}
}
if (finding.proposal) {
lines.push(` → ${finding.proposal.label} (${finding.proposal.blastRadius})`);
}
return lines.join('\n');
};
/**
* Builds the full briefing.
*
* `scan` is the result of the assistant's own booking scan — `{ rows,
* truncated }` — so the briefing inherits the same data the rest of the
* catalog answers from and cannot disagree with it.
*/
export const buildBriefing = (scan, now = wallClockNow()) => {
// Raw `/admin/bookings` rows in, agent rows out. Without this the rules read
// undefined on every field and the briefing reports an all-clear board.
const rows = toAgentRows(scan?.rows);
const truncated = Boolean(scan?.truncated);
// The SAME engine the Exceptions banner runs, deliberately.
//
// This used to call a private set of rules in signals.js that duplicated five
// of the skills and hardcoded their thresholds. The two drifted immediately —
// signals.js flagged a stalled rider at 25 minutes while DoorstepStallSkill
// used 20 — so the chat panel and the banner disagreed about the same parcel,
// and a threshold moved in Agent Studio changed one surface but not the other.
//
// Going through the registry means there is one definition of every rule, one
// place thresholds live, and the chat panel sees every enabled skill rather
// than the five somebody happened to reimplement here.
const findings = AgentFactory.synthesizeDefaultAgent().evaluateTelemetry(rows, now);
if (!findings.length) {
return {
headline: ALL_CLEAR,
detail: truncated
? 'Note: the booking scan hit its page budget, so this covers the most recent orders rather than every one.'
: `Checked ${rows.length} open and recent bookings.`,
findings,
metric: { value: 0, label: 'issues found' }
};
}
const critical = findings.filter((f) => f.severity === 'critical');
const affected = new Set(findings.flatMap((f) => f.rows.map((r) => r.bookingid))).size;
const headline = critical.length
? `${critical.length} thing${critical.length === 1 ? '' : 's'} need${critical.length === 1 ? 's' : ''} attention now, across ${countPrefix(truncated)}${affected} order${affected === 1 ? '' : 's'}.`
: `Nothing critical, but ${findings.length} thing${findings.length === 1 ? '' : 's'} worth looking at across ${countPrefix(truncated)}${affected} order${affected === 1 ? '' : 's'}.`;
const body = findings.map((f) => renderFinding(f, truncated)).join('\n\n');
const footer = truncated
? '\n\nThe booking scan hit its page budget, so these counts are a floor — there may be more.'
: '';
return {
headline,
detail: `${body}${footer}`,
findings,
metric: { value: findings.length, label: findings.length === 1 ? 'issue found' : 'issues found' }
};
};

View File

@@ -0,0 +1,53 @@
// ==============================|| Doormile AI — scan row adapter ||============================== //
//
// The assistant's booking scan returns RAW rows straight off `/admin/bookings`
// — `status`, `createdat`, `assignedmileruserid`, `serviceoptions[0]` — not the
// normalised delivery rows the Deliveries page renders. The two shapes share
// almost no field names, so the agent's rules cannot read a scan row directly.
//
// This adapter is the seam. It exists as its own file because getting it wrong
// fails silently in the worst possible way: every rule reads `undefined`, every
// rule matches nothing, and the agent cheerfully reports "nothing needs
// attention" on a board that is on fire. That is precisely the failure mode
// CLAUDE.md §3 exists to prevent, and it is invisible without a test.
//
// The status derivation deliberately goes through the console's own
// `mapBookingStatusToDeliveryStatus` rather than a local copy — the same
// function the Deliveries page's table calls. That is the house rule (never
// hand-roll a second data path) and it is also what guarantees the agent and
// the screen can never disagree about what state a parcel is in.
import { mapBookingStatusToDeliveryStatus } from '@/api/doormile/queries';
/** The rider's arrival stamp, which the API has spelled several ways. */
const reachedAtOf = (b) => b.reachedat ?? b.reached_at ?? b.reachedAt ?? b.reachedtime ?? b.reached_time;
/** Rider display name, from whichever join carried it. */
const riderNameOf = (b) =>
b.milername || b.ridername || b.assignedmilername || b.miler?.name || undefined;
/**
* One raw booking row → the shape signals.js reads.
*
* Every field is derived, never invented: a raw row that carries no service
* option yields `expecteddeliverytime: undefined`, and the SLA rules skip it
* rather than treating a missing promise as a kept or broken one.
*/
export const toAgentRow = (b) => ({
bookingid: b.bookingid ?? b.id,
orderid: b.bookingno || (b.bookingid ? `#${b.bookingid}` : '—'),
// Booking status, consignment status and reachedat resolved by the console's
// own mapper — including the Converted_To_Consignment handoff, where the
// lifecycle moves onto the consignment and the booking's status freezes.
orderstatus: mapBookingStatusToDeliveryStatus(b),
orderdate: b.createdat || b.orderdate || b.updatedat,
expecteddeliverytime: b.serviceoptions?.[0]?.estimateddeliveryat,
reachedat: reachedAtOf(b),
userid: b.assignedmileruserid ?? b.mileruserid,
ridername: riderNameOf(b)
});
/** A whole scan's rows, adapted. Non-array input yields an empty list. */
export const toAgentRows = (rows) => (Array.isArray(rows) ? rows.map(toAgentRow) : []);

View File

@@ -0,0 +1,44 @@
// ==============================|| Doormile AI — shared agent primitives ||============================== //
//
// What is left after the rules moved out.
//
// This file used to hold its own copy of five detection rules — breached SLA,
// at-risk SLA, aging unassigned work, doorstep stalls, rider saturation — each
// with a hardcoded threshold in a local THRESHOLDS object. Every one of those
// rules already existed as a skill in skills/definitions/, with the same
// finding id and a threshold an operator can tune in Agent Studio.
//
// Two implementations of one rule is a bug with a delay fuse, and this one had
// already fired: signals.js flagged a doorstep stall at 25 minutes while
// DoorstepStallSkill used 20, so the chat panel and the Exceptions banner
// disagreed about the same rider, out of the box, before anyone touched a
// slider. Worse, the hardcoded copy could not see the registry at all — tuning
// a threshold moved the banner and left the chat panel where it was.
//
// The rules now live in exactly one place: skills/definitions/. Everything that
// needs findings goes through AgentFactory, which reads the registry, so there
// is one definition per rule and one place a threshold can be changed.
//
// What remains here is the small shared vocabulary that is genuinely common to
// every consumer and belongs to none of them.
import dayjs from 'dayjs';
/**
* "Now", in the same form every Doormile timestamp is stored in.
*
* NOT `new Date().toISOString()`. That returns UTC, and parseDoormileTimestamp
* deliberately strips any zone marker and reads the remaining digits as IST
* wall-clock — so an ISO string would hand the rules a clock running 5h30m
* slow, and every parcel less than five and a half hours overdue would look
* like it was still in the future. The agent would report an all-clear board
* through most of a working day.
*
* This was a live bug. It passed every unit test, because the tests all passed
* `now` explicitly and only the default was wrong.
*/
export const wallClockNow = () => dayjs().format('YYYY-MM-DD HH:mm:ss');
/** Shown when every enabled skill returns no findings. */
export const ALL_CLEAR =
'Nothing needs attention right now — no breached promises, no stalled riders, and no aging unassigned work.';

View File

@@ -22,6 +22,7 @@ import {
getTenantLocations
} from 'pages/api/doormileApi';
import { getalltenants, getallridersummary } from 'pages/api/api';
import { buildBriefing } from '@/lib/assistant/agent/briefing';
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
import { getRowBatchId, getBatchLabel, BATCHES } from 'utils/batchBucket';
import { STATUS } from 'themes/dt/tokens';
@@ -745,6 +746,48 @@ const COMPARE_TRIGGER = /\bvs\b|\bversus\b|\bcompared?\s*to\b|\bcompare\b/i;
const MULTI_SPLIT = /\band\b|,|\+|&/i;
const INTENTS = [
{
// The ops briefing: every monitoring skill enabled in the agent registry,
// run over the same booking scan the rest of the catalog answers from —
// the SAME engine as the Exceptions banner (AgentFactory), so the chat and
// the banner can never disagree about a parcel. Ported from
// feat/agentic-ops-layer (docs/agent-platform-plan.md, Phase 3).
//
// FIRST on purpose: later intents match bare "orders"/"riders", which
// appear in most phrasings of a sweep.
//
// Narrower than the branch on purpose. The branch also claimed
// "late/overdue/delayed orders", which would have stolen the existing
// delayed-orders answer (a count on the Orders taxonomy) and replaced it
// with a briefing. Only an explicit request for a sweep lands here.
id: 'opsBriefing',
label: 'What needs attention right now — e.g. "what needs attention", "anything going wrong", "ops check"',
match: (text) => {
const t = String(text).toLowerCase();
const asksForSweep =
/\b(what|anything|any)\b.{0,24}\b(needs?\s+(my\s+)?attention|going\s+wrong|at\s+risk|urgent|on\s+fire)\b/.test(t) ||
/\b(ops|operations|status|health|daily)\s+(check|sweep|briefing|brief)\b/.test(t) ||
/\bwhat\s+should\s+i\s+(do|look\s+at)\b/.test(t);
return asksForSweep ? {} : null;
},
run: async () => {
const scan = await scanBookings();
const briefing = buildBriefing(scan);
return {
headline: briefing.headline,
detail: briefing.detail,
metric: briefing.metric,
sourceCalls: [
scanCall(
scan,
briefing.findings.length
? `${briefing.findings.length} finding${briefing.findings.length === 1 ? '' : 's'} over ${scan.rows.length} rows`
: `no issues over ${scan.rows.length} rows`
)
]
};
}
},
{
// Ordered ahead of BOTH create triggers. "repeat yesterday's orders"
// contains "orders", so createBulkOrders and createOrder would otherwise

View File

@@ -0,0 +1,123 @@
// ==============================|| Doormile AI — Skill Registry ||============================== //
//
// The console's eight monitoring skills: their RULES live in code
// (./definitions), their SETTINGS — enabled, thresholds — live in the backend
// agent registry (/admin/ai/skills, edited in Settings → Skills & Tools). This
// class joins the two.
//
// On the feat/agentic-ops-layer branch these settings were kept in the
// browser's localStorage, so a threshold tuned on one machine changed nothing
// on any other, and two operators could look at the same board through
// different rules. Ported onto main in Phase 3 of docs/agent-platform-plan.md,
// the settings come from the registry instead, and nothing is stored locally.
//
// Until the registry answers (first load, a network error, a partner login
// that is not allowed to read it) every skill runs on its code defaults, and
// `getSource()` says so, so the banner can tell the operator which settings it
// is running on rather than implying they are the tuned ones.
import { SlaGuardianSkill } from './definitions/SlaGuardianSkill';
import { DoorstepStallSkill } from './definitions/DoorstepStallSkill';
import { FleetBalancerSkill } from './definitions/FleetBalancerSkill';
import { HighValueCodSkill } from './definitions/HighValueCodSkill';
import { RiderBatterySafetySkill } from './definitions/RiderBatterySafetySkill';
import { HubCongestionSkill } from './definitions/HubCongestionSkill';
import { LateDispatchSkill } from './definitions/LateDispatchSkill';
import { CashExposureSkill } from './definitions/CashExposureSkill';
export const DEFAULT_SKILLS = [
SlaGuardianSkill,
DoorstepStallSkill,
FleetBalancerSkill,
HighValueCodSkill,
RiderBatterySafetySkill,
HubCongestionSkill,
LateDispatchSkill,
CashExposureSkill
];
/** A stored value outside the definition's range falls back to its default. */
const inRange = (config, v) =>
typeof v === 'number' && Number.isFinite(v) && v >= config.min && v <= config.max;
class SkillRegistryClass {
constructor() {
this.skills = new Map();
this.listeners = new Set();
this.config = {}; // { [skillId]: { enabled: boolean, thresholds: { [key]: number } } }
this.source = 'defaults';
DEFAULT_SKILLS.forEach((skill) => this.skills.set(skill.id, skill));
}
/**
* Adopt the registry's settings. `rows` is the /admin/ai/skills response;
* rows for skills this console does not implement are ignored.
*/
applyRegistryConfig(rows) {
const next = {};
(Array.isArray(rows) ? rows : []).forEach((row) => {
if (!row || !this.skills.has(row.skillid)) return;
next[row.skillid] = {
enabled: Boolean(row.enabled),
thresholds: row.thresholds && typeof row.thresholds === 'object' ? row.thresholds : {}
};
});
this.config = next;
this.source = 'registry';
this.notify();
}
/** Forget the registry's settings and run on code defaults. */
clearRegistryConfig() {
if (this.source === 'defaults' && !Object.keys(this.config).length) return;
this.config = {};
this.source = 'defaults';
this.notify();
}
/** 'registry' when running on the tuned settings, 'defaults' otherwise. */
getSource() {
return this.source;
}
getAllSkills() {
return Array.from(this.skills.values()).map((skill) => {
const cfg = this.config[skill.id];
const enabled = cfg ? cfg.enabled : (skill.defaultEnabled ?? true);
const effectiveThresholds = {};
Object.entries(skill.thresholds || {}).forEach(([key, config]) => {
const stored = cfg?.thresholds?.[key];
effectiveThresholds[key] = { ...config, currentValue: inRange(config, stored) ? stored : config.value };
});
return { ...skill, enabled, effectiveThresholds };
});
}
getActiveSkills() {
return this.getAllSkills().filter((s) => s.enabled);
}
getSkill(skillId) {
return this.getAllSkills().find((s) => s.id === skillId);
}
subscribe(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
notify() {
const all = this.getAllSkills();
this.listeners.forEach((fn) => {
try {
fn(all);
} catch {
// One listener failing must not stop the others hearing the change.
}
});
}
}
export const SkillRegistry = new SkillRegistryClass();

View File

@@ -0,0 +1,161 @@
// ==============================|| Skill: Cash Exposure Agent ||============================== //
//
// Monitors the total live COD (Cash on Delivery) cash a single rider is carrying
// across ALL their active orders. When total exposure exceeds the safe limit,
// proposes a mandatory cash handoff stop at the nearest hub.
//
// This is distinct from HighValueCodSkill, which monitors per-order value.
// This skill monitors per-rider TOTAL cash accumulation across their full run.
const ACTIVE_COD_STATUSES = new Set(['accepted', 'picked', 'active', 'arrived']);
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const CashExposureSkill = {
id: 'skill_cash_exposure',
name: 'Cash Exposure Agent',
description: 'Monitors total live COD cash per rider across all active orders. Flags when a rider carries unsafe cash levels and proposes hub handoff.',
category: 'loss_prevention',
icon: 'ShieldCheck',
// OFF by default (2026-09-29): the rows this rule reads carry no COD amount per order: /admin/bookings does not preload bookingpayments.
// Enabled, it would read undefined on every row and report an all-clear board —
// the silent failure normalise.js warns about. Turn it on only once the data exists.
dataGap: 'no COD amount per order: /admin/bookings does not preload bookingpayments',
defaultEnabled: false,
thresholds: {
maxCashPerRider: {
label: 'Max Safe Cash Per Rider',
description: 'Maximum total COD cash (₹) a single rider should carry simultaneously across all active orders.',
value: 10000,
min: 2000,
max: 50000,
step: 1000,
unit: '₹'
},
warningCashPercent: {
label: 'Warning Threshold (%)',
description: 'Percentage of max cash limit at which to issue a warning (before it becomes critical).',
value: 75,
min: 50,
max: 95,
step: 5,
unit: '%'
}
},
instructions: `
- A rider carrying excessive COD cash is a major financial risk if the phone dies, the rider is delayed, or there is a dispute.
- Flag riders above the cash threshold for a mandatory hub cash handoff before continuing deliveries.
- Critical: stop new COD orders being assigned to flagged riders until handoff is confirmed.
`,
tools: [
{
name: 'enforce_cash_handoff',
description: 'Proposes a mandatory cash handoff stop at the nearest hub for an overexposed rider.',
parameters: {
type: 'object',
properties: {
milerId: { type: 'number', description: 'Rider user ID carrying excessive cash' },
totalCash: { type: 'number', description: 'Total COD cash the rider currently holds (₹)' },
hubId: { type: 'string', description: 'Nearest hub for handoff' }
},
required: ['milerId', 'totalCash']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'cash_handoff',
milerId: args.milerId,
totalCash: args.totalCash,
hubId: args.hubId || 'nearest',
blastRadius: `Reroutes rider to nearest hub for cash handoff of ₹${args.totalCash?.toLocaleString('en-IN')}. Pauses new COD order assignments to this rider until confirmed.`
}
})
}
],
evaluate: (rows = [], _now, currentThresholds = {}) => {
const maxCash = currentThresholds.maxCashPerRider ?? 10000;
const warningPct = (currentThresholds.warningCashPercent ?? 75) / 100;
const warningLimit = maxCash * warningPct;
const findings = [];
// Group active COD orders by rider
const byRider = {};
rows
.filter((r) =>
ACTIVE_COD_STATUSES.has(String(r?.orderstatus || '').toLowerCase()) &&
r?.paymenttype &&
String(r.paymenttype).toLowerCase().includes('cod') &&
r?.userid
)
.forEach((r) => {
const uid = r.userid;
if (!byRider[uid]) {
byRider[uid] = { riderId: uid, riderName: r.ridername || r.miler_name || `Rider ${uid}`, orders: [] };
}
byRider[uid].orders.push(r);
});
const criticalRiders = [];
const warningRiders = [];
Object.values(byRider).forEach((riderData) => {
const total = riderData.orders.reduce((sum, r) => {
const amt = parseFloat(r.cod_amount || r.orderamount || r.totalamount || 0);
return sum + (isNaN(amt) ? 0 : amt);
}, 0);
riderData.totalCash = total;
if (total >= maxCash) {
criticalRiders.push(riderData);
} else if (total >= warningLimit) {
warningRiders.push(riderData);
}
});
criticalRiders.sort((a, b) => b.totalCash - a.totalCash);
warningRiders.sort((a, b) => b.totalCash - a.totalCash);
if (criticalRiders.length) {
findings.push({
id: 'cash_exposure_critical',
skillId: 'skill_cash_exposure',
severity: 'critical',
title: `${criticalRiders.length} rider${criticalRiders.length === 1 ? '' : 's'} carrying ≥₹${maxCash.toLocaleString('en-IN')} in COD cash`,
why: `Total COD cash per rider exceeds the safe limit of ₹${maxCash.toLocaleString('en-IN')}. Immediate hub handoff required.`,
count: criticalRiders.length,
rows: criticalRiders.flatMap((rd) => rd.orders),
detail: criticalRiders.slice(0, 8).map(
(rd) => `${rd.riderName} — ₹${rd.totalCash.toLocaleString('en-IN')} across ${rd.orders.length} active COD order${rd.orders.length === 1 ? '' : 's'}`
),
proposal: {
label: 'Enforce cash handoff at hub',
tool: 'enforce_cash_handoff',
scope: criticalRiders.flatMap((rd) => rd.orders),
blastRadius: 'Reroutes affected riders to nearest hub for mandatory cash handoff. New COD assignments paused.'
}
});
}
if (warningRiders.length) {
findings.push({
id: 'cash_exposure_warning',
skillId: 'skill_cash_exposure',
severity: 'warning',
title: `${warningRiders.length} rider${warningRiders.length === 1 ? '' : 's'} approaching cash limit (>${Math.round(warningPct * 100)}% of ₹${maxCash.toLocaleString('en-IN')})`,
why: `Total COD cash is approaching the safe threshold. Plan a hub handoff stop for these riders.`,
count: warningRiders.length,
rows: warningRiders.flatMap((rd) => rd.orders),
detail: warningRiders.slice(0, 8).map(
(rd) => `${rd.riderName} — ₹${rd.totalCash.toLocaleString('en-IN')} across ${rd.orders.length} orders`
)
});
}
return findings;
}
};

View File

@@ -0,0 +1,90 @@
// ==============================|| Skill: Doorstep Stall Rescuer ||============================== //
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
const minutesBetween = (from, to) => {
const a = parseDoormileTimestamp(from);
const b = parseDoormileTimestamp(to);
if (!a.isValid() || !b.isValid()) return null;
return b.diff(a, 'minute');
};
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const DoorstepStallSkill = {
id: 'skill_doorstep_stall',
name: 'Doorstep Stall Rescuer',
description: 'Flags riders who stamped arrival at consignee doorstep but have been stalled with no progress.',
category: 'rider_operations',
icon: 'MapPinOff',
defaultEnabled: true,
thresholds: {
arrivedStalledMin: {
label: 'Doorstep Stall Timeout',
description: 'Minutes after arriving at customer location before flagging as stalled.',
value: 20,
min: 10,
max: 60,
step: 5,
unit: 'min'
}
},
instructions: `
- When a rider is stalled at a delivery location, suggest verifying customer contact or initiating automated phone verification.
`,
tools: [
{
name: 'ping_stalled_rider',
description: 'Sends an in-app ping to check if the rider needs help with gate access or customer contact.',
parameters: {
type: 'object',
properties: {
milerId: { type: 'number', description: 'Miler ID to reach' },
message: { type: 'string', description: 'Optional prompt text' }
},
required: ['milerId']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'notify_miler',
milerId: args.milerId,
blastRadius: `Pings rider #${args.milerId} with doorstep assistance prompt.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const stallLimit = currentThresholds.arrivedStalledMin ?? 20;
const stalled = rows
.filter((r) => String(r.orderstatus || '').toLowerCase() === 'arrived' && r.reachedat)
.map((r) => ({ row: r, waitingMin: minutesBetween(r.reachedat, now) }))
.filter((m) => m.waitingMin !== null && m.waitingMin >= stallLimit)
.sort((a, b) => b.waitingMin - a.waitingMin);
if (!stalled.length) return [];
return [
{
id: 'stalled_at_door',
skillId: 'skill_doorstep_stall',
severity: 'critical',
title: `${stalled.length} rider${stalled.length === 1 ? '' : 's'} stalled at door for > ${stallLimit}m`,
why: 'Rider app reported arrival but delivery confirmation has not completed. Possible gate, address, or absent consignee issue.',
count: stalled.length,
rows: stalled.map((m) => m.row),
detail: stalled.slice(0, 8).map((m) => `${ref(m.row)} — ${m.row.ridername || 'Rider'} waiting ${m.waitingMin} min`),
proposal: {
label: 'Message waiting riders & support',
tool: 'notifyRider',
scope: stalled.filter((m) => m.row.userid).map((m) => m.row),
blastRadius: 'Sends an assistance check-in to stalled delivery riders.'
}
}
];
}
};

View File

@@ -0,0 +1,87 @@
// ==============================|| Skill: Fleet Load Balancer ||============================== //
const OPEN_STATUSES = new Set(['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped']);
const isOpen = (row) => OPEN_STATUSES.has(String(row?.orderstatus || '').toLowerCase());
export const FleetBalancerSkill = {
id: 'skill_fleet_balancer',
name: 'Fleet Load Balancer',
description: 'Prevents rider overload by flagging riders at maximum active parcel capacity while queue work waits.',
category: 'fleet_optimization',
icon: 'Scale',
defaultEnabled: true,
thresholds: {
riderActiveCap: {
label: 'Rider Active Capacity Cap',
description: 'Maximum concurrent active orders a single rider should carry simultaneously.',
value: 3,
min: 1,
max: 6,
step: 1,
unit: 'orders'
}
},
instructions: `
- Keep rider concurrent workload at or below the specified active cap to prevent cumulative delivery delays.
- Proactively redistribute un-picked orders from overloaded riders to available nearby fleet.
`,
tools: [
{
name: 'balance_rider_load',
description: 'Reallocates queued unpicked orders from a saturated rider to an available rider.',
parameters: {
type: 'object',
properties: {
fromMilerId: { type: 'number', description: 'Overloaded miler ID' },
toMilerId: { type: 'number', description: 'Available miler ID' }
},
required: ['fromMilerId', 'toMilerId']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'rebalance_workload',
fromMilerId: args.fromMilerId,
toMilerId: args.toMilerId,
blastRadius: `Transfers queued parcels from Rider #${args.fromMilerId} to Rider #${args.toMilerId}.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const activeCap = currentThresholds.riderActiveCap ?? 3;
const safeRows = Array.isArray(rows) ? rows : [];
const unassignedCount = safeRows.filter((r) => String(r?.orderstatus || '').toLowerCase() === 'pending').length;
const byRider = new Map();
safeRows.filter((r) => isOpen(r) && r.userid).forEach((r) => {
const key = String(r.userid);
if (!byRider.has(key)) byRider.set(key, { riderId: r.userid, name: r.ridername || `Rider ${r.userid}`, rows: [] });
byRider.get(key).rows.push(r);
});
const saturated = [...byRider.values()]
.filter((entry) => entry.rows.length >= activeCap)
.sort((a, b) => b.rows.length - a.rows.length);
if (!saturated.length || unassignedCount === 0) return [];
return [
{
id: 'rider_saturation',
skillId: 'skill_fleet_balancer',
severity: 'watch',
title: `${saturated.length} rider${saturated.length === 1 ? ' is' : 's are'} at capacity (≥${activeCap} jobs) while orders queue`,
why: `Riders carrying ${activeCap}+ in-flight jobs are capped and will not receive newly queued orders.`,
count: saturated.length,
rows: saturated.flatMap((entry) => entry.rows),
detail: saturated.slice(0, 8).map((entry) => `${entry.name} — ${entry.rows.length} concurrent orders`)
}
];
}
};

View File

@@ -0,0 +1,93 @@
// ==============================|| Skill: High-Value COD Escort ||============================== //
const OPEN_STATUSES = new Set(['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped']);
const isOpen = (row) => OPEN_STATUSES.has(String(row?.orderstatus || '').toLowerCase());
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const HighValueCodSkill = {
id: 'skill_high_value_cod',
name: 'High-Value Cash Guardian',
description: 'Audits large Cash-on-Delivery consignments to prevent loss, fraud, or unverified deliveries.',
category: 'loss_prevention',
icon: 'ShieldCheck',
// OFF by default (2026-09-29): the rows this rule reads carry no payment amount or payment mode: /admin/bookings does not preload bookingpayments.
// Enabled, it would read undefined on every row and report an all-clear board —
// the silent failure normalise.js warns about. Turn it on only once the data exists.
dataGap: 'no payment amount or payment mode: /admin/bookings does not preload bookingpayments',
defaultEnabled: false,
thresholds: {
codRiskThresholdAmount: {
label: 'High-Value COD Threshold (₹)',
description: 'Cash-on-Delivery amount above which an order is classified as high-risk cash asset.',
value: 3000,
min: 1000,
max: 20000,
step: 500,
unit: '₹'
}
},
instructions: `
- High-value COD consignments must require OTP verification at the doorstep and verified senior rider assignment.
`,
tools: [
{
name: 'enforce_otp_verification',
description: 'Flags an order as strictly requiring consignee OTP delivery confirmation before handover.',
parameters: {
type: 'object',
properties: {
bookingId: { type: 'number', description: 'Target booking ID' }
},
required: ['bookingId']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'require_otp',
bookingId: args.bookingId,
blastRadius: `Enables mandatory OTP confirmation on mobile app for parcel #${args.bookingId}.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const minAmount = currentThresholds.codRiskThresholdAmount ?? 3000;
const safeRows = Array.isArray(rows) ? rows : [];
const highValueCod = safeRows.filter((r) => {
if (!isOpen(r)) return false;
const isCod = String(r.paymenttype || r.payment_mode || '').toLowerCase().includes('cod') ||
String(r.paymentstatus || '').toLowerCase() === 'pending';
const amount = Number(r.orderamount || r.totalamount || r.price || 0);
return isCod && amount >= minAmount;
});
if (!highValueCod.length) return [];
return [
{
id: 'high_value_cod_alert',
skillId: 'skill_high_value_cod',
severity: 'watch',
title: `${highValueCod.length} high-value COD consignment${highValueCod.length === 1 ? '' : 's'} (≥ ₹${minAmount.toLocaleString()}) active`,
why: `Large cash collection in transit requires mandatory OTP handover verification.`,
count: highValueCod.length,
rows: highValueCod,
detail: highValueCod.slice(0, 8).map((r) => {
const amt = Number(r.orderamount || r.totalamount || r.price || 0);
return `${ref(r)} — ₹${amt.toLocaleString()} (${r.orderstatus || 'active'})`;
}),
proposal: {
label: 'Verify OTP requirements',
tool: 'enforce_otp_verification',
scope: highValueCod,
blastRadius: 'Sets strict OTP delivery guard on high-value cash deliveries.'
}
}
];
}
};

View File

@@ -0,0 +1,105 @@
// ==============================|| Skill: Hub Congestion Agent ||============================== //
//
// Detects parcels that have been sitting at a hub for too long without
// being picked up by a rider. These represent blocked capacity at the hub
// and direct SLA risk for the customer. This is critical for hub operations
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
const HUB_STATUSES = new Set(['pending', 'accepted']); // at hub, not yet picked
const minutesBetween = (from, to) => {
const a = parseDoormileTimestamp(from);
const b = parseDoormileTimestamp(to);
if (!a.isValid() || !b.isValid()) return null;
return b.diff(a, 'minute');
};
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const HubCongestionSkill = {
id: 'skill_hub_congestion',
name: 'Hub Congestion Agent',
description: 'Detects parcels sitting at a hub without rider pickup beyond the dwell threshold. Dispatches nearest idle rider.',
category: 'sla_management',
icon: 'ShieldAlert',
defaultEnabled: true,
thresholds: {
hubDwellMinutes: {
label: 'Hub Dwell Threshold',
description: 'Minutes a parcel can sit at hub without a rider pickup before raising a congestion alert.',
value: 45,
min: 15,
max: 120,
step: 5,
unit: 'min'
}
},
instructions: `
- Parcels sitting at a hub beyond the dwell threshold block capacity for the next batch.
- Identify the nearest idle rider in the same zone and dispatch immediately.
- Prefer riders with the fewest active orders to avoid overloading.
`,
tools: [
{
name: 'dispatch_hub_idle_parcels',
description: 'Proposes dispatching a nearby idle rider to collect stalled hub parcels.',
parameters: {
type: 'object',
properties: {
hubId: { type: 'string', description: 'Hub identifier where parcels are stalled' },
bookingIds: { type: 'array', items: { type: 'number' }, description: 'Booking IDs to dispatch' },
milerId: { type: 'number', description: 'Target idle rider user ID' }
},
required: ['bookingIds']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'dispatch_hub_parcels',
hubId: args.hubId || 'unknown',
bookingIds: args.bookingIds,
milerId: args.milerId,
blastRadius: `Dispatches rider to collect ${args.bookingIds?.length || 'stalled'} parcel(s) from hub. Updates parcel status to In-Transit.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const dwellLimit = currentThresholds.hubDwellMinutes ?? 45;
const findings = [];
const stalled = rows
.filter((r) => HUB_STATUSES.has(String(r?.orderstatus || '').toLowerCase()))
.map((r) => ({ row: r, dwellMin: minutesBetween(r.orderdate, now) }))
.filter((m) => m.dwellMin !== null && m.dwellMin >= dwellLimit)
.sort((a, b) => b.dwellMin - a.dwellMin);
if (stalled.length) {
findings.push({
id: 'hub_parcel_stalled',
skillId: 'skill_hub_congestion',
severity: 'critical',
title: `${stalled.length} parcel${stalled.length === 1 ? '' : 's'} stalled at hub for >${dwellLimit}m`,
why: 'Parcel has been at the hub beyond the acceptable dwell time with no rider assigned for pickup.',
count: stalled.length,
rows: stalled.map((m) => m.row),
detail: stalled.slice(0, 8).map((m) => `${ref(m.row)} — at hub for ${m.dwellMin} min`),
proposal: {
label: 'Dispatch idle rider to hub',
tool: 'dispatch_hub_idle_parcels',
scope: stalled.map((m) => m.row),
blastRadius: 'Assigns nearest idle zone rider to collect and dispatch all stalled parcels.'
}
});
}
return findings;
}
};

View File

@@ -0,0 +1,133 @@
// ==============================|| Skill: Late Dispatch Agent ||============================== //
//
// Flags orders that have been accepted/created but not yet dispatched to a rider
// within the acceptable dispatch window during business hours.
// Late dispatch at creation cascades into late delivery at the door.
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
const PENDING_DISPATCH_STATUSES = new Set(['pending']);
const minutesBetween = (from, to) => {
const a = parseDoormileTimestamp(from);
const b = parseDoormileTimestamp(to);
if (!a.isValid() || !b.isValid()) return null;
return b.diff(a, 'minute');
};
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const LateDispatchSkill = {
id: 'skill_late_dispatch',
name: 'Late Dispatch Agent',
description: 'Flags orders accepted but sitting in queue without dispatch for too long. Triggers auto-assignment to prevent cascading delays.',
category: 'sla_management',
icon: 'ShieldAlert',
defaultEnabled: true,
thresholds: {
lateDispatchMinutes: {
label: 'Dispatch Deadline',
description: 'Minutes after order creation before flagging as late dispatch.',
value: 30,
min: 10,
max: 90,
step: 5,
unit: 'min'
},
criticalDispatchMinutes: {
label: 'Critical Dispatch Deadline',
description: 'Minutes after order creation before escalating to critical alert.',
value: 60,
min: 30,
max: 180,
step: 10,
unit: 'min'
}
},
instructions: `
- Orders waiting for dispatch beyond the threshold accumulate delay that can't be recovered at the doorstep.
- Auto-assign to the nearest available rider immediately.
- Prioritize orders with the earliest SLA deadlines when multiple are pending.
`,
tools: [
{
name: 'trigger_auto_dispatch',
description: 'Proposes auto-assigning late-pending orders to available riders in zone.',
parameters: {
type: 'object',
properties: {
bookingIds: { type: 'array', items: { type: 'number' }, description: 'Booking IDs requiring immediate dispatch' },
reason: { type: 'string', description: 'Dispatch urgency reason' }
},
required: ['bookingIds']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'auto_dispatch',
bookingIds: args.bookingIds,
reason: args.reason || 'Late dispatch threshold exceeded',
blastRadius: `Auto-assigns ${args.bookingIds?.length || 'late'} order(s) to nearest available zone riders. Rider notifications will be sent.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const lateThreshold = currentThresholds.lateDispatchMinutes ?? 30;
const criticalThreshold = currentThresholds.criticalDispatchMinutes ?? 60;
const findings = [];
const allPending = rows
.filter((r) => PENDING_DISPATCH_STATUSES.has(String(r?.orderstatus || '').toLowerCase()))
.map((r) => ({ row: r, waitMin: minutesBetween(r.orderdate, now) }))
.filter((m) => m.waitMin !== null && m.waitMin >= lateThreshold)
.sort((a, b) => b.waitMin - a.waitMin);
const critical = allPending.filter((m) => m.waitMin >= criticalThreshold);
const warning = allPending.filter((m) => m.waitMin >= lateThreshold && m.waitMin < criticalThreshold);
if (critical.length) {
findings.push({
id: 'late_dispatch_critical',
skillId: 'skill_late_dispatch',
severity: 'critical',
title: `${critical.length} order${critical.length === 1 ? '' : 's'} critically late for dispatch (>${criticalThreshold}m)`,
why: `Order has been sitting in queue for over ${criticalThreshold} minutes with no rider assigned. Delivery SLA is at severe risk.`,
count: critical.length,
rows: critical.map((m) => m.row),
detail: critical.slice(0, 8).map((m) => `${ref(m.row)} — waiting ${m.waitMin} min for dispatch`),
proposal: {
label: 'Auto-dispatch to available riders',
tool: 'trigger_auto_dispatch',
scope: critical.map((m) => m.row),
blastRadius: 'Immediately assigns nearest available riders. Overrides manual queue priority.'
}
});
}
if (warning.length) {
findings.push({
id: 'late_dispatch_warning',
skillId: 'skill_late_dispatch',
severity: 'warning',
title: `${warning.length} order${warning.length === 1 ? '' : 's'} approaching dispatch deadline (>${lateThreshold}m)`,
why: `Order has been waiting ${lateThreshold}+ minutes for rider assignment. Act now to prevent SLA breach.`,
count: warning.length,
rows: warning.map((m) => m.row),
detail: warning.slice(0, 8).map((m) => `${ref(m.row)} — waiting ${m.waitMin} min`),
proposal: {
label: 'Dispatch now',
tool: 'trigger_auto_dispatch',
scope: warning.map((m) => m.row),
blastRadius: 'Assigns available riders to pending orders based on zone proximity.'
}
});
}
return findings;
}
};

View File

@@ -0,0 +1,91 @@
// ==============================|| Skill: Rider Battery & Device Safety ||============================== //
const OPEN_STATUSES = new Set(['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped']);
const isOpen = (row) => OPEN_STATUSES.has(String(row?.orderstatus || '').toLowerCase());
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const RiderBatterySafetySkill = {
id: 'skill_rider_battery_safety',
name: 'Rider Device & SOS Safety',
description: 'Monitors rider device telemetry to prevent unreachability due to low battery during active deliveries.',
category: 'rider_safety',
icon: 'BatteryWarning',
// OFF by default (2026-09-29): the rows this rule reads carry no battery level: it lives on the rider profile (milerprofiles.batterypercentage), not on booking rows.
// Enabled, it would read undefined on every row and report an all-clear board —
// the silent failure normalise.js warns about. Turn it on only once the data exists.
dataGap: 'no battery level: it lives on the rider profile (milerprofiles.batterypercentage), not on booking rows',
defaultEnabled: false,
thresholds: {
criticalBatteryPercent: {
label: 'Critical Battery Level (%)',
description: 'Threshold below which rider device is considered at risk of shutdown.',
value: 15,
min: 5,
max: 30,
step: 5,
unit: '%'
}
},
instructions: `
- When a rider has critical battery while carrying in-transit orders, advise charging or offloading remaining pending pickups.
`,
tools: [
{
name: 'alert_low_battery_rider',
description: 'Notifies rider to connect portable charger or report to nearest hub.',
parameters: {
type: 'object',
properties: {
milerId: { type: 'number', description: 'Target miler user ID' }
},
required: ['milerId']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'battery_alert',
milerId: args.milerId,
blastRadius: `Sends device power warning notification to Rider #${args.milerId}.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const minBattery = currentThresholds.criticalBatteryPercent ?? 15;
const safeRows = Array.isArray(rows) ? rows : [];
const atRisk = safeRows.filter((r) => {
if (!isOpen(r) || !r.userid) return false;
const battery = Number(r.battery_percentage || r.battery_level || r.battery || 100);
return battery > 0 && battery <= minBattery;
});
if (!atRisk.length) return [];
return [
{
id: 'rider_critical_battery',
skillId: 'skill_rider_battery_safety',
severity: 'warning',
title: `${atRisk.length} active delivery under low rider device battery (≤ ${minBattery}%)`,
why: `Rider phone is nearing shutdown, risking lost GPS telemetry and customer communication failure.`,
count: atRisk.length,
rows: atRisk,
detail: atRisk.slice(0, 8).map((r) => {
const bat = r.battery_percentage || r.battery_level || r.battery || 'Low';
return `${ref(r)} — ${r.ridername || 'Rider'} (${bat}% battery)`;
}),
proposal: {
label: 'Send power alert',
tool: 'alert_low_battery_rider',
scope: atRisk,
blastRadius: 'Sends charging prompt to rider.'
}
}
];
}
};

View File

@@ -0,0 +1,157 @@
// ==============================|| Skill: SLA Breach Guardian ||============================== //
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
const OPEN_STATUSES = new Set(['pending', 'accepted', 'arrived', 'picked', 'active', 'skipped']);
const IN_FLIGHT_STATUSES = new Set(['picked', 'active']);
const isOpen = (row) => OPEN_STATUSES.has(String(row?.orderstatus || '').toLowerCase());
const minutesBetween = (from, to) => {
const a = parseDoormileTimestamp(from);
const b = parseDoormileTimestamp(to);
if (!a.isValid() || !b.isValid()) return null;
return b.diff(a, 'minute');
};
const ref = (row) => row?.orderid || row?.bookingno || (row?.bookingid ? `#${row.bookingid}` : '—');
export const SlaGuardianSkill = {
id: 'skill_sla_guardian',
name: 'SLA Breach Guardian',
description: 'Monitors promised customer delivery ETAs and flags both breached and imminent SLA violations.',
category: 'sla_management',
icon: 'ShieldAlert',
defaultEnabled: true,
thresholds: {
slaRiskWindowMin: {
label: 'At-Risk Warning Window',
description: 'Minutes before promised delivery window to flag an un-picked parcel as at-risk.',
value: 45,
min: 15,
max: 90,
step: 5,
unit: 'min'
},
unassignedAgingMin: {
label: 'Unassigned Aging Threshold',
description: 'Minutes an order can remain pending with no assigned rider before raising an alarm.',
value: 60,
min: 15,
max: 120,
step: 5,
unit: 'min'
}
},
instructions: `
- When orders exceed promised delivery ETA or are within the risk window without being in flight, prioritize immediate reassignment.
- Select nearby available riders with high historical on-time fulfillment rates.
`,
tools: [
{
name: 'reassign_sla_critical_order',
description: 'Proposes transferring an SLA-at-risk order to an optimal nearby rider.',
parameters: {
type: 'object',
properties: {
bookingId: { type: 'number', description: 'Booking ID to reassign' },
targetMilerId: { type: 'number', description: 'Target miler user ID' },
reason: { type: 'string', description: 'Justification' }
},
required: ['bookingId', 'targetMilerId']
},
handler: async (args) => ({
success: true,
proposal: {
action: 'reassign_miler',
bookingId: args.bookingId,
milerId: args.targetMilerId,
reason: args.reason || 'SLA risk mitigation',
blastRadius: `Reassigns parcel #${args.bookingId} and updates customer ETA tracking.`
}
})
}
],
evaluate: (rows = [], now, currentThresholds = {}) => {
const riskWindow = currentThresholds.slaRiskWindowMin ?? 45;
const agingWindow = currentThresholds.unassignedAgingMin ?? 60;
const findings = [];
// 1. Breached SLA
const breached = rows
.filter((r) => isOpen(r) && r.expecteddeliverytime)
.map((r) => ({ row: r, overdueMin: minutesBetween(r.expecteddeliverytime, now) }))
.filter((m) => m.overdueMin !== null && m.overdueMin > 0)
.sort((a, b) => b.overdueMin - a.overdueMin);
if (breached.length) {
findings.push({
id: 'sla_breached',
skillId: 'skill_sla_guardian',
severity: 'critical',
title: `${breached.length} ${breached.length === 1 ? 'parcel is' : 'parcels are'} past promised delivery time`,
why: 'Estimated delivery time on service option has elapsed and parcel is not yet delivered.',
count: breached.length,
rows: breached.map((m) => m.row),
detail: breached.slice(0, 8).map((m) => `${ref(m.row)} — ${m.overdueMin} min overdue, status: ${m.row.orderstatus}`),
proposal: {
label: 'Notify assigned riders',
tool: 'notifyRider',
scope: breached.filter((m) => m.row.userid).map((m) => m.row),
blastRadius: 'Sends an urgent priority notification to the assigned rider.'
}
});
}
// 2. At-Risk SLA
const atRisk = rows
.filter((r) => isOpen(r) && r.expecteddeliverytime && !IN_FLIGHT_STATUSES.has(String(r.orderstatus).toLowerCase()))
.map((r) => ({ row: r, dueInMin: minutesBetween(now, r.expecteddeliverytime) }))
.filter((m) => m.dueInMin !== null && m.dueInMin > 0 && m.dueInMin <= riskWindow)
.sort((a, b) => a.dueInMin - b.dueInMin);
if (atRisk.length) {
findings.push({
id: 'sla_at_risk',
skillId: 'skill_sla_guardian',
severity: 'warning',
title: `${atRisk.length} parcel${atRisk.length === 1 ? '' : 's'} due within ${riskWindow}m and not yet moving`,
why: 'Inside the delivery promise window but parcel pickup has not begun.',
count: atRisk.length,
rows: atRisk.map((m) => m.row),
detail: atRisk.slice(0, 8).map((m) => `${ref(m.row)} — due in ${m.dueInMin} min, status: ${m.row.orderstatus}`)
});
}
// 3. Unassigned Aging
const aging = rows
.filter((r) => String(r.orderstatus || '').toLowerCase() === 'pending')
.map((r) => ({ row: r, ageMin: minutesBetween(r.orderdate, now) }))
.filter((m) => m.ageMin !== null && m.ageMin >= agingWindow)
.sort((a, b) => b.ageMin - a.ageMin);
if (aging.length) {
findings.push({
id: 'unassigned_aging',
skillId: 'skill_sla_guardian',
severity: 'warning',
title: `${aging.length} booking${aging.length === 1 ? '' : 's'} unassigned for > ${agingWindow}m`,
why: 'Order has been sitting in queue with no assigned miler.',
count: aging.length,
rows: aging.map((m) => m.row),
detail: aging.slice(0, 8).map((m) => `${ref(m.row)} — waiting ${m.ageMin} min`),
proposal: {
label: 'Auto-assign available rider',
tool: 'assignMiler',
scope: aging.map((m) => m.row),
blastRadius: 'Initiates dispatch matching across idle zone riders.'
}
});
}
return findings;
}
};

View File

@@ -0,0 +1,10 @@
export { SkillRegistry, DEFAULT_SKILLS } from './SkillRegistry';
export { useSkillRegistrySync } from './useSkillRegistrySync';
export { SlaGuardianSkill } from './definitions/SlaGuardianSkill';
export { DoorstepStallSkill } from './definitions/DoorstepStallSkill';
export { FleetBalancerSkill } from './definitions/FleetBalancerSkill';
export { HighValueCodSkill } from './definitions/HighValueCodSkill';
export { RiderBatterySafetySkill } from './definitions/RiderBatterySafetySkill';
export { HubCongestionSkill } from './definitions/HubCongestionSkill';
export { LateDispatchSkill } from './definitions/LateDispatchSkill';
export { CashExposureSkill } from './definitions/CashExposureSkill';

View File

@@ -0,0 +1,27 @@
import { useEffect } from 'react';
import { useAiSkills } from '@/lib/doormileHooks';
import { SkillRegistry } from './SkillRegistry';
/**
* Keeps the in-memory SkillRegistry in step with the backend agent registry.
*
* Mounted once, in the console shell, so the Exceptions banner and the chat's
* "what needs attention" briefing both run on the same, tuned settings. A
* change saved in Settings → Skills & Tools invalidates the registry query,
* this refetches, and every subscriber re-evaluates.
*
* `enabled` is false for partner-tenant logins: the registry is Doormile-staff
* only (the backend answers 403), so they run on code defaults, which the
* banner states.
*/
export function useSkillRegistrySync(enabled = true) {
const { data, isError } = useAiSkills({ enabled, retry: false });
useEffect(() => {
if (data) SkillRegistry.applyRegistryConfig(data);
}, [data]);
useEffect(() => {
if (isError || !enabled) SkillRegistry.clearRegistryConfig();
}, [isError, enabled]);
}

View File

@@ -0,0 +1,75 @@
/**
* Who may onboard a new client (create a tenant and its console login).
*
* The server is the real gate — POST /admin/clients/onboard answers 403 to
* anyone outside CLIENT_ONBOARDING_OWNERS (default admin@doormile.com), and
* only for Doormile staff with roleid 1. This list only decides whether the
* console SHOWS the page and its links, so keep it matching the server's.
*/
const DEFAULT_OWNERS = 'admin@doormile.com';
export const CLIENT_ONBOARDING_OWNERS = String(import.meta.env?.VITE_CLIENT_ONBOARDING_OWNERS || DEFAULT_OWNERS)
.split(',')
.map((e) => e.trim().toLowerCase())
.filter(Boolean);
/**
* @param {any} user the signed-in console user (AuthContext)
* @param {boolean} isClient true for a partner-tenant login
*/
export function canOnboardClients(user, isClient) {
if (!user || isClient) return false;
const email = String(user.email || '').trim().toLowerCase();
const role = String(user.role || '').toLowerCase();
return CLIENT_ONBOARDING_OWNERS.includes(email) && (role === 'admin' || String(user.roleid) === '1');
}
/** A random password an operator can hand over: 14 chars, no look-alikes. */
export function generateClientPassword(length = 14) {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789@#%&*';
const bytes = new Uint32Array(length);
globalThis.crypto.getRandomValues(bytes);
let out = '';
for (let i = 0; i < length; i += 1) out += chars[bytes[i] % chars.length];
// Guarantee a digit and a letter, so a generated password is never all one class.
if (!/\d/.test(out)) out = `${out.slice(0, -1)}7`;
if (!/[a-z]/i.test(out)) out = `k${out.slice(1)}`;
return out;
}
/** Client-side mirror of the server's validation, for inline errors. */
export function validateOnboarding(form) {
const errors = {};
const phone = String(form.phone || '').replace(/\D/g, '');
if (String(form.companyname || '').trim().length < 2) errors.companyname = 'Enter the company name';
if (String(form.contactname || '').trim().length < 2) errors.contactname = "Enter the contact person's name";
if (!/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(String(form.email || '').trim())) errors.email = 'Enter a valid email address';
if (!/^[6-9]\d{9}$/.test(phone)) errors.phone = 'Enter a 10-digit mobile number';
if (!form.applocationid) errors.applocationid = "Choose the client's operating city";
const pw = String(form.password || '');
if (pw.length < 8) errors.password = 'At least 8 characters';
else if (pw.length > 72) errors.password = 'At most 72 characters';
else if (pw.toLowerCase() === String(form.email || '').trim().toLowerCase() || pw === phone) {
errors.password = 'Must not be the email or the phone number';
}
if (form.confirm !== pw) errors.confirm = 'The passwords do not match';
return errors;
}
/**
* Why onboarding cannot run, from the failed GET /admin/clients/cities. The
* page shows this instead of an empty city list: the likeliest cause is a
* console pointed at an API that does not have the onboarding endpoints yet.
*/
export function onboardingUnavailableMessage(err) {
const status = err?.response?.status;
if (status === 404) {
return 'This API server does not have client onboarding yet. The backend changes that add it need to be deployed first.';
}
if (status === 403) {
return 'The server refused this login. Client onboarding is only for the admin@doormile.com account, signed in as Doormile staff.';
}
if (status === 401) return 'Your session has expired. Sign in again.';
if (!err?.response) return 'Could not reach the API server. Check your connection and try again.';
return err.response.data?.message || `The server answered ${status}. Try again shortly.`;
}

View File

@@ -49,6 +49,11 @@ export const calculateDrivingRoute = async (origin, destination) => {
const data = await response.json();
if (data.routes && data.routes.length > 0) {
const route = data.routes[0];
// `distance` stays whole km: it is what pricing and bulk upload bill on,
// and changing its precision would change invoices. `meters` is the
// unrounded road length, for display only — a 120 m drop rounds to
// 0 km here and must not be shown as "0 km" or floored to "1 km".
const meters = Math.max(0, Math.round(route.distance));
const distanceKm = Math.round(route.distance / 1000);
const durationMin = Number.isFinite(route.duration) ? Math.round(route.duration / 60) : null;
// OSRM geojson coordinates are [lng, lat] -> convert to Leaflet [lat, lng]
@@ -56,6 +61,7 @@ export const calculateDrivingRoute = async (origin, destination) => {
lastRouteDurationMin = durationMin;
return {
distance: Math.max(0, distanceKm),
meters,
minutes: durationMin,
polyline: polyline.length > 0 ? polyline : [[lat1, lon1], [lat2, lon2]],
resolved: true
@@ -81,6 +87,7 @@ export const calculateDrivingRoute = async (origin, destination) => {
lastRouteDurationMin = null;
return {
distance: aerialDistance,
meters: Math.round(R * c * 1.3 * 1000),
minutes: null,
polyline: [[lat1, lon1], [lat2, lon2]],
resolved: true
@@ -95,6 +102,30 @@ export const calculateDrivingDistance = async (origin, destination) => {
return res.distance;
};
/**
* Formats a road length for display: metres under 1 km (to the nearest 10 m),
* km with one decimal above it. Returns '—' when the length is unknown.
* @param {number} meters
*/
export const formatRouteDistance = (meters) => {
if (meters === null || meters === undefined) return '—';
const m = Number(meters);
if (!Number.isFinite(m) || m < 0) return '—';
const tens = Math.round(m / 10) * 10;
if (tens < 1000) return `${tens} m`;
return `${Number((m / 1000).toFixed(1))} km`;
};
/**
* Formats a drive time for display. OSRM reports seconds; a sub-minute leg
* rounds to 0 and must read "< 1 min", not fall through to a default.
* @param {number|null} minutes
*/
export const formatRouteDuration = (minutes) => {
if (minutes === null || minutes === undefined || !Number.isFinite(Number(minutes))) return '—';
return Number(minutes) < 1 ? '< 1 min' : `${minutes} mins`;
};
/**
* Calculates total charge based on distance and pricing tier
*/

View File

@@ -67,6 +67,14 @@ export const KEYS = {
carrierPricing: (page, limit) => ['doormile', 'carrier-pricing', page, limit],
appLocations: ['doormile', 'app-locations'],
// AI agent registry. One prefix, so a write invalidates agents, skills,
// tools and the audit together — a skill toggle changes an agent's counts.
aiRegistry: ['doormile', 'ai'],
aiAgents: ['doormile', 'ai', 'agents'],
aiSkills: ['doormile', 'ai', 'skills'],
aiTools: ['doormile', 'ai', 'tools'],
aiAudit: ['doormile', 'ai', 'audit'],
};
/* ── Shared helpers ───────────────────────────────────────────────────────── */
@@ -179,6 +187,23 @@ export const useTenant = (id, options) =>
export const useCreateTenant = () =>
useDoormileMutation({ mutationFn: api.createAdminTenant, invalidates: [KEYS.tenants], successMessage: 'Client created' });
export const useOnboardedClients = (options) =>
useQuery({ queryKey: [...KEYS.tenants, 'onboarded'], queryFn: api.getOnboardedClients, staleTime: 30_000, ...options });
export const useOnboardingCities = (options) =>
useQuery({ queryKey: [...KEYS.tenants, 'onboarding-cities'], queryFn: api.getOnboardingCities, staleTime: 300_000, ...options });
// No toasts: the page shows its own success panel and inline errors.
export const useOnboardClient = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.onboardClient,
onSuccess: (result) => {
if (result?.success !== false) queryClient.invalidateQueries({ queryKey: KEYS.tenants });
},
});
};
export const useUpdateTenant = () =>
useDoormileMutation({
mutationFn: ({ id, data }) => api.updateAdminTenant(id, data),
@@ -792,3 +817,57 @@ export const useOrderPercentages = (startdate, enddate, options) =>
staleTime: 30_000,
...options,
});
/* ── AI agent registry (Settings → Skills & Tools) ────────────────────────── */
export const useAiAgents = (options) =>
useQuery({ queryKey: KEYS.aiAgents, queryFn: api.getAiAgents, staleTime: 30_000, ...options });
export const useAiSkills = (options) =>
useQuery({ queryKey: KEYS.aiSkills, queryFn: api.getAiSkills, staleTime: 30_000, ...options });
export const useAiTools = (options) =>
useQuery({ queryKey: KEYS.aiTools, queryFn: api.getAiTools, staleTime: 5 * 60_000, ...options });
export const useUpdateAiSkill = () =>
useDoormileMutation({
mutationFn: ({ id, patch }) => api.updateAiSkill(id, patch),
invalidates: [KEYS.aiRegistry],
successMessage: 'Skill updated',
});
export const useCreateAiSkill = () =>
useDoormileMutation({
mutationFn: (skill) => api.createAiSkill(skill),
invalidates: [KEYS.aiRegistry],
successMessage: 'Skill registered',
});
export const useUpdateAiAgent = () =>
useDoormileMutation({
mutationFn: ({ id, patch }) => api.updateAiAgent(id, patch),
invalidates: [KEYS.aiRegistry],
successMessage: 'Agent updated',
});
// Read-only, and it changes as agents work: refetched every minute while open.
export const useAiInsights = (days = 7, options) =>
useQuery({
queryKey: [...KEYS.aiRegistry, 'insights', days],
queryFn: () => api.getAiInsights(days),
staleTime: 30_000,
refetchInterval: 60_000,
...options,
});
// A playground run. No toasts and no invalidation: it changes nothing, and the
// Test tab shows its own result or error inline.
export const useRunAiPlayground = () => useMutation({ mutationFn: api.runAiPlayground });
export const useAiDecisions = (params = {}, options) =>
useQuery({
queryKey: [...KEYS.aiRegistry, 'decisions', params],
queryFn: () => api.getAiDecisions(params),
staleTime: 30_000,
...options,
});