@paigy/mcp 0.25.0 → 0.25.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/{chunk-WGZHKF6D.js → chunk-2374V6WK.js} +114 -55
- package/dist/{chunk-LDJ7IVYZ.js → chunk-FEAIZ6DA.js} +1 -1
- package/dist/{chunk-A2MIHI5X.js → chunk-LUY5MXDM.js} +59 -9
- package/dist/index.js +74 -40
- package/dist/listen.js +2 -2
- package/dist/onboard.js +2 -2
- package/dist/statusline.js +1 -1
- package/package.json +13 -14
package/README.md
CHANGED
|
@@ -11,9 +11,9 @@ A voice inbox for your AI agents. This MCP server lets an agent **notify a user*
|
|
|
11
11
|
/plugin install paigy
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
The Paigy MCP connects automatically.
|
|
15
|
-
unpaired,
|
|
16
|
-
|
|
14
|
+
The Paigy MCP connects automatically. On the first Paigy interaction while
|
|
15
|
+
unpaired, the agent shows a pairing code; approve it in Paigy. `/paigy-onboard`
|
|
16
|
+
remains the terminal-only fallback.
|
|
17
17
|
|
|
18
18
|
> [!NOTE]
|
|
19
19
|
> If you are installing the plugin inside an active Claude Code session, you must type `/reload-plugins` (or restart the session) afterward so the terminal client starts the MCP server and exposes the new tools to the agent.
|
|
@@ -2274,6 +2274,48 @@ async function reach(url, init) {
|
|
|
2274
2274
|
throw new Error(`${NETWORK_MSG} (${e?.message ?? String(e)})`);
|
|
2275
2275
|
}
|
|
2276
2276
|
}
|
|
2277
|
+
var TITLE_MAX = 90;
|
|
2278
|
+
var CHUNKS_MAX = 8;
|
|
2279
|
+
var CHUNK_MAX = 300;
|
|
2280
|
+
var ASK_MAX = 1e3;
|
|
2281
|
+
var OPTION_MAX = 80;
|
|
2282
|
+
var NEEDS_MAX = 6;
|
|
2283
|
+
var UNSPEAKABLE = /```|\n/;
|
|
2284
|
+
function lintNotify(req) {
|
|
2285
|
+
const problems = [];
|
|
2286
|
+
if (req.ask !== void 0) {
|
|
2287
|
+
if (req.ask.length > ASK_MAX)
|
|
2288
|
+
problems.push(`ask is ${req.ask.length} chars \u2014 state the need and why it matters now in \u2264${ASK_MAX}; move detail into a smaller follow-up`);
|
|
2289
|
+
if (UNSPEAKABLE.test(req.ask))
|
|
2290
|
+
problems.push("ask contains code fences or newlines \u2014 write it as plain prose (it may be read aloud on a call)");
|
|
2291
|
+
for (const n of req.needs ?? []) {
|
|
2292
|
+
if (n.length > OPTION_MAX) problems.push(`need "${n.slice(0, 40)}\u2026" is too long \u2014 each need is a short phrase (\u2264${OPTION_MAX} chars)`);
|
|
2293
|
+
}
|
|
2294
|
+
if ((req.needs?.length ?? 0) > NEEDS_MAX)
|
|
2295
|
+
problems.push(`${req.needs?.length} needs \u2014 cap at ${NEEDS_MAX}; a call can't cover more in one conversation, split the rest into a second ask`);
|
|
2296
|
+
return problems;
|
|
2297
|
+
}
|
|
2298
|
+
const spoken = req.urgency === "call" || req.urgency === "banner";
|
|
2299
|
+
if (req.context) {
|
|
2300
|
+
if (req.context.title.length > TITLE_MAX)
|
|
2301
|
+
problems.push(`title is ${req.context.title.length} chars \u2014 shorten to \u2264${TITLE_MAX} (it's what shows on the banner / gets spoken on a ring)`);
|
|
2302
|
+
if (UNSPEAKABLE.test(req.context.title))
|
|
2303
|
+
problems.push("title contains code fences or newlines \u2014 one plain-prose line");
|
|
2304
|
+
if (req.context.description.length > CHUNKS_MAX)
|
|
2305
|
+
problems.push(`${req.context.description.length} description chunks \u2014 cap at ${CHUNKS_MAX}; merge or drop the rest`);
|
|
2306
|
+
for (const [i, chunk] of req.context.description.entries()) {
|
|
2307
|
+
if (chunk.length > CHUNK_MAX)
|
|
2308
|
+
problems.push(`description[${i}] is ${chunk.length} chars \u2014 split it into standalone points of \u2264${CHUNK_MAX}`);
|
|
2309
|
+
}
|
|
2310
|
+
if (spoken && UNSPEAKABLE.test(req.context.description.join(" ")))
|
|
2311
|
+
problems.push("urgency is 'call'/'banner' but the description has code fences/newlines-in-chunk \u2014 rewrite in spoken register (it will be read aloud)");
|
|
2312
|
+
}
|
|
2313
|
+
for (const o of req.options ?? []) {
|
|
2314
|
+
if (o.label.length > OPTION_MAX)
|
|
2315
|
+
problems.push(`option label "${o.label.slice(0, 40)}\u2026" is ${o.label.length} chars \u2014 labels must read at a glance (\u2264${OPTION_MAX}); move detail into the description`);
|
|
2316
|
+
}
|
|
2317
|
+
return problems;
|
|
2318
|
+
}
|
|
2277
2319
|
var ContextSchema = z.object({
|
|
2278
2320
|
title: z.string().min(1).describe("One-line headline of what you need (required, non-empty)."),
|
|
2279
2321
|
description: z.array(z.string().min(1)).min(1).describe("Semantic chunks of detail (each a standalone, non-empty piece). The user can select chunks to ask you to expand.")
|
|
@@ -2301,7 +2343,7 @@ var TransformSchema = z.enum([
|
|
|
2301
2343
|
var OptionSchema = z.object({
|
|
2302
2344
|
id: z.string(),
|
|
2303
2345
|
label: z.string(),
|
|
2304
|
-
// .describe() flows into the MCP
|
|
2346
|
+
// .describe() flows into the MCP contact JSON schema (zodToJsonSchema), so
|
|
2305
2347
|
// the constraints below are what an agent reads when deciding to use these.
|
|
2306
2348
|
html: z.string().max(16384).describe(
|
|
2307
2349
|
"Optional sandboxed HTML/CSS preview for a visual 'pick one' (shown in the option card). Untrusted-sandboxed: NO JavaScript, NO external network or images \u2014 inline CSS and data: URIs only; <=16KB. Use for layout/CSS mockups, tables, diffs. For a hosted image use `image` instead."
|
|
@@ -2412,6 +2454,13 @@ var NotifyRequestSchema = z.object({
|
|
|
2412
2454
|
waiting: z.enum(["none", "soft", "hard"]).optional().describe(
|
|
2413
2455
|
"With `ask`: what happens to your work while you wait. 'none' = you're just informing the user. 'soft' = you'd like an answer but can keep working. 'hard' = you are stopped until they answer (reaches them urgently and escalates to a real phone call if unanswered). Replaces urgencyHint + blocking \u2014 send this one field."
|
|
2414
2456
|
),
|
|
2457
|
+
/** #575: a RELAY of the user's explicitly stated preference, never the agent's
|
|
2458
|
+
* choice. Outranks waiting in both directions: 'call' rings even for a
|
|
2459
|
+
* waiting:'none' "call me when it's done"; 'message' never rings even for
|
|
2460
|
+
* waiting:'hard'. */
|
|
2461
|
+
channel: z.enum(["call", "message"]).optional().describe(
|
|
2462
|
+
"Only if the user explicitly said how to reach them \u2014 'call me' \u2192 'call', 'just message/text me' \u2192 'message'. Omit otherwise; Paigy picks."
|
|
2463
|
+
),
|
|
2415
2464
|
confirmStyle: z.enum(["yesno", "approve"]).default("yesno").describe(
|
|
2416
2465
|
"Labels for a select:'confirm' paige \u2014 'yesno' (Yes/No) or 'approve' (Approve/Deny). Ignored unless select is 'confirm'."
|
|
2417
2466
|
),
|
|
@@ -2469,8 +2518,9 @@ function deriveAsk(req) {
|
|
|
2469
2518
|
const text = req.ask.trim();
|
|
2470
2519
|
const firstSentence = (/^[^.!?\n]+[.!?]?/.exec(text)?.[0] ?? text).trim();
|
|
2471
2520
|
const title = firstSentence.length > 90 ? `${firstSentence.slice(0, 87).trimEnd()}\u2026` : firstSentence;
|
|
2472
|
-
const
|
|
2473
|
-
const
|
|
2521
|
+
const hinted = req.urgencyHint === "now" ? "call" : req.urgencyHint === "soon" ? "banner" : req.urgencyHint === "whenever" ? "inbox" : req.urgency;
|
|
2522
|
+
const urgency = req.channel === "call" ? "call" : req.channel === "message" && hinted === "call" ? "banner" : hinted;
|
|
2523
|
+
const { ask: _ask, needs, urgencyHint: _hint, channel: _channel, ...rest } = req;
|
|
2474
2524
|
return {
|
|
2475
2525
|
...rest,
|
|
2476
2526
|
context: { title, description: [text] },
|
|
@@ -2491,17 +2541,17 @@ var TurnSchema = z.object({
|
|
|
2491
2541
|
reply: z.string()
|
|
2492
2542
|
});
|
|
2493
2543
|
var UserAnswerSchema = z.discriminatedUnion("kind", [
|
|
2494
|
-
z.object({ kind: z.literal("option"), optionId: z.string() }),
|
|
2544
|
+
z.object({ kind: z.literal("option"), optionId: z.string(), label: z.string().optional() }),
|
|
2495
2545
|
z.object({ kind: z.literal("text"), text: z.string() }),
|
|
2496
2546
|
z.object({ kind: z.literal("ignored") }),
|
|
2497
|
-
z.object({ kind: z.literal("multi"), optionIds: z.array(z.string()) }),
|
|
2498
|
-
z.object({ kind: z.literal("ranked"), optionIds: z.array(z.string()) }),
|
|
2547
|
+
z.object({ kind: z.literal("multi"), optionIds: z.array(z.string()), labels: z.array(z.string()).optional() }),
|
|
2548
|
+
z.object({ kind: z.literal("ranked"), optionIds: z.array(z.string()), labels: z.array(z.string()).optional() }),
|
|
2499
2549
|
z.object({ kind: z.literal("clarify"), chunks: z.array(z.string()).min(1) }),
|
|
2500
2550
|
z.object({ kind: z.literal("confirm"), approved: z.boolean() }),
|
|
2501
2551
|
z.object({ kind: z.literal("turns"), turns: z.array(TurnSchema).min(1) })
|
|
2502
2552
|
]);
|
|
2503
2553
|
var IntentSchema = z.object({
|
|
2504
|
-
kind: z.enum(["defer", "delegate", "channel"]),
|
|
2554
|
+
kind: z.enum(["defer", "delegate", "channel", "question"]),
|
|
2505
2555
|
detail: z.string(),
|
|
2506
2556
|
/** Landed defer (#397): the MCP parses common spoken forms ("in 20 minutes",
|
|
2507
2557
|
* "after lunch") against the agent machine's clock — the user's — and attaches
|
|
@@ -2539,11 +2589,20 @@ var AwaitItemSchema = z.discriminatedUnion("type", [
|
|
|
2539
2589
|
/** Seconds until remindAt, server-computed — pass straight to ScheduleWakeup. */
|
|
2540
2590
|
remindInSeconds: z.number()
|
|
2541
2591
|
}),
|
|
2592
|
+
/** The awaited ask was REPLACED by a newer notification on its thread (e.g. a
|
|
2593
|
+
* post-feedback revision, #633) — the user will never answer this id. Stop
|
|
2594
|
+
* awaiting it; the live ask is the thread's newest turn (await that one, or
|
|
2595
|
+
* re-orient via get_thread / check_replies). */
|
|
2596
|
+
z.object({
|
|
2597
|
+
type: z.literal("superseded"),
|
|
2598
|
+
threadId: z.string(),
|
|
2599
|
+
notificationId: z.string()
|
|
2600
|
+
}),
|
|
2542
2601
|
z.object({ type: z.literal("idle") })
|
|
2543
2602
|
]);
|
|
2544
2603
|
var CallbackTriggerSchema = z.enum(["on_done", "on_blocked", "scheduled"]);
|
|
2545
2604
|
var ScheduleCallbackSchema = z.object({
|
|
2546
|
-
threadId: z.string().describe("The thread to call back on (from a prior
|
|
2605
|
+
threadId: z.string().describe("The thread to call back on (from a prior contact / reply / request)."),
|
|
2547
2606
|
trigger: CallbackTriggerSchema,
|
|
2548
2607
|
dueInSeconds: z.number().int().positive().optional().describe("For 'scheduled' only: how many seconds from now to fire."),
|
|
2549
2608
|
note: z.string().optional().describe("What to tell the user when you follow up.")
|
|
@@ -2571,7 +2630,7 @@ var PendingRepliesSchema = z.object({
|
|
|
2571
2630
|
z.object({ threadId: z.string(), notificationId: z.string(), createdAt: z.string() })
|
|
2572
2631
|
),
|
|
2573
2632
|
/** User-initiated requests addressed to this agent; act on them and reply via
|
|
2574
|
-
*
|
|
2633
|
+
* contact on the same threadId. Keeps reappearing until you call
|
|
2575
2634
|
* set_task_state on its notificationId. */
|
|
2576
2635
|
requests: z.array(
|
|
2577
2636
|
z.object({
|
|
@@ -2586,7 +2645,7 @@ var PendingRepliesSchema = z.object({
|
|
|
2586
2645
|
),
|
|
2587
2646
|
/** Callbacks you owe the user that are now DUE (you said you'd follow up when done,
|
|
2588
2647
|
* if blocked, or at a time that has passed). Re-surfaced every sweep until you
|
|
2589
|
-
* fulfill one by calling
|
|
2648
|
+
* fulfill one by calling contact on its threadId. */
|
|
2590
2649
|
owedCallbacks: z.array(
|
|
2591
2650
|
z.object({ threadId: z.string(), trigger: CallbackTriggerSchema, note: z.string() })
|
|
2592
2651
|
),
|
|
@@ -2595,6 +2654,26 @@ var PendingRepliesSchema = z.object({
|
|
|
2595
2654
|
* went idle. Report a real state (set_task_state) or continue the work. */
|
|
2596
2655
|
stalled: z.array(
|
|
2597
2656
|
z.object({ threadId: z.string(), notificationId: z.string(), title: z.string().nullable(), startedAt: z.string() })
|
|
2657
|
+
),
|
|
2658
|
+
/** The queue rail (#614, pending/design.md): the same replies + requests, grouped by
|
|
2659
|
+
* thread and ordered oldest-thread-first, so you work ONE thread at a time — fold all of
|
|
2660
|
+
* a thread's `items` into a single turn rather than interleaving threads. `busy` = the
|
|
2661
|
+
* thread already has a turn in progress (younger than the stall cutoff); let it finish and
|
|
2662
|
+
* ride the next turn. `items` are that thread's replies/requests in arrival order; the
|
|
2663
|
+
* full payload for each is in the flat `replies`/`requests` arrays (matched by
|
|
2664
|
+
* notificationId). Derived, never stored — a crashed agent recomputes it exactly. */
|
|
2665
|
+
threads: z.array(
|
|
2666
|
+
z.object({
|
|
2667
|
+
threadId: z.string(),
|
|
2668
|
+
busy: z.boolean(),
|
|
2669
|
+
items: z.array(
|
|
2670
|
+
z.object({
|
|
2671
|
+
kind: z.enum(["reply", "request"]),
|
|
2672
|
+
notificationId: z.string(),
|
|
2673
|
+
at: z.string()
|
|
2674
|
+
})
|
|
2675
|
+
)
|
|
2676
|
+
})
|
|
2598
2677
|
)
|
|
2599
2678
|
});
|
|
2600
2679
|
var NotifyResponseSchema = z.object({
|
|
@@ -2730,6 +2809,15 @@ var UserSettingsSchema = z.object({
|
|
|
2730
2809
|
* must not silently reset this privacy choice. Absent = leave unchanged on
|
|
2731
2810
|
* write, 'hosted' on read (see store.ts). */
|
|
2732
2811
|
voiceMode: z.enum(["hosted", "on_device"]).optional(),
|
|
2812
|
+
/** Per-user ring budget (#603): calls per rolling day before further calls
|
|
2813
|
+
* degrade to banner. Absent = the global default (25). A number, never a
|
|
2814
|
+
* bypass — every account keeps a ceiling. No UI; set per user for testing. */
|
|
2815
|
+
callBudget: z.number().int().min(1).max(500).optional(),
|
|
2816
|
+
/** Per-user voice-call tuning (#318): raw knobs forwarded to the call bot's
|
|
2817
|
+
* payload['tuning'] (e.g. { silence_s: 3.5 } — a longer pause window for a
|
|
2818
|
+
* slower speaker). No API-side semantics; the bot resolves each key with its
|
|
2819
|
+
* own defaults. Set per user (no UI yet); absent = bot defaults. */
|
|
2820
|
+
voiceTuning: z.record(z.string(), z.union([z.number(), z.string()])).optional(),
|
|
2733
2821
|
/** Opt-in to real-phone (PSTN) calls when the app can't ring. Optional, not
|
|
2734
2822
|
* defaulted — an older client PATCHing the full object must not clobber it. */
|
|
2735
2823
|
pstnCalls: z.boolean().optional(),
|
|
@@ -2799,7 +2887,12 @@ var HandoffSchema = z.object({
|
|
|
2799
2887
|
notes: z.array(z.string().min(1)).min(1),
|
|
2800
2888
|
/** A sibling connection to dispatch directly to (token id or agent nickname). Same-account
|
|
2801
2889
|
* only; omit to leave the thread for the user to hand off in the app. */
|
|
2802
|
-
target: z.string().optional()
|
|
2890
|
+
target: z.string().optional(),
|
|
2891
|
+
/** Write the note as a RECAP (kind:'recap', #617): a summary turn that supersedes the
|
|
2892
|
+
* thread's earlier turns for rehydration — get_thread returns the latest recap + only
|
|
2893
|
+
* the turns after it. Handoff-to-a-successor and handoff-to-yourself-later are the
|
|
2894
|
+
* same primitive; a recap is one whose audience includes you. */
|
|
2895
|
+
recap: z.boolean().optional()
|
|
2803
2896
|
});
|
|
2804
2897
|
var DeliveryModeSchema = z.enum(["poll", "self_hosted"]);
|
|
2805
2898
|
var RegisterDeliverySchema = z.object({ mode: DeliveryModeSchema });
|
|
@@ -3588,6 +3681,13 @@ async function getThread(threadId) {
|
|
|
3588
3681
|
if (!res.ok) throw new Error(`get_thread failed: ${res.status} ${await res.text()}`);
|
|
3589
3682
|
return await res.json();
|
|
3590
3683
|
}
|
|
3684
|
+
async function searchThreads(q) {
|
|
3685
|
+
const res = ensureAuthed(await reach(`${BACKEND_URL}/api/search?q=${encodeURIComponent(q)}`, {
|
|
3686
|
+
headers: { authorization: `Bearer ${readToken()}` }
|
|
3687
|
+
}));
|
|
3688
|
+
if (!res.ok) throw new Error(`search_threads failed: ${res.status} ${await res.text()}`);
|
|
3689
|
+
return await res.json();
|
|
3690
|
+
}
|
|
3591
3691
|
async function checkReplies() {
|
|
3592
3692
|
const token = readToken();
|
|
3593
3693
|
const res = ensureAuthed(await reach(`${BACKEND_URL}/api/pending`, {
|
|
@@ -3643,52 +3743,11 @@ async function handoff(req) {
|
|
|
3643
3743
|
if (!res.ok) throw new Error(`handoff failed: ${res.status} ${await res.text()}`);
|
|
3644
3744
|
return await res.json();
|
|
3645
3745
|
}
|
|
3646
|
-
var TITLE_MAX = 90;
|
|
3647
|
-
var CHUNKS_MAX = 8;
|
|
3648
|
-
var CHUNK_MAX = 300;
|
|
3649
|
-
var ASK_MAX = 600;
|
|
3650
|
-
var OPTION_MAX = 80;
|
|
3651
|
-
var NEEDS_MAX = 6;
|
|
3652
|
-
var UNSPEAKABLE = /```|\n/;
|
|
3653
|
-
function lintNotify(req) {
|
|
3654
|
-
const problems = [];
|
|
3655
|
-
if (req.ask !== void 0) {
|
|
3656
|
-
if (req.ask.length > ASK_MAX)
|
|
3657
|
-
problems.push(`ask is ${req.ask.length} chars \u2014 state the need and why it matters now in \u2264${ASK_MAX}; move detail into a smaller follow-up`);
|
|
3658
|
-
if (UNSPEAKABLE.test(req.ask))
|
|
3659
|
-
problems.push("ask contains code fences or newlines \u2014 write it as plain prose (it may be read aloud on a call)");
|
|
3660
|
-
for (const n of req.needs ?? []) {
|
|
3661
|
-
if (n.length > OPTION_MAX) problems.push(`need "${n.slice(0, 40)}\u2026" is too long \u2014 each need is a short phrase (\u2264${OPTION_MAX} chars)`);
|
|
3662
|
-
}
|
|
3663
|
-
if ((req.needs?.length ?? 0) > NEEDS_MAX)
|
|
3664
|
-
problems.push(`${req.needs?.length} needs \u2014 cap at ${NEEDS_MAX}; a call can't cover more in one conversation, split the rest into a second ask`);
|
|
3665
|
-
return problems;
|
|
3666
|
-
}
|
|
3667
|
-
const spoken = req.urgency === "call" || req.urgency === "banner";
|
|
3668
|
-
if (req.context) {
|
|
3669
|
-
if (req.context.title.length > TITLE_MAX)
|
|
3670
|
-
problems.push(`title is ${req.context.title.length} chars \u2014 shorten to \u2264${TITLE_MAX} (it's what shows on the banner / gets spoken on a ring)`);
|
|
3671
|
-
if (UNSPEAKABLE.test(req.context.title))
|
|
3672
|
-
problems.push("title contains code fences or newlines \u2014 one plain-prose line");
|
|
3673
|
-
if (req.context.description.length > CHUNKS_MAX)
|
|
3674
|
-
problems.push(`${req.context.description.length} description chunks \u2014 cap at ${CHUNKS_MAX}; merge or drop the rest`);
|
|
3675
|
-
for (const [i, chunk] of req.context.description.entries()) {
|
|
3676
|
-
if (chunk.length > CHUNK_MAX)
|
|
3677
|
-
problems.push(`description[${i}] is ${chunk.length} chars \u2014 split it into standalone points of \u2264${CHUNK_MAX}`);
|
|
3678
|
-
}
|
|
3679
|
-
if (spoken && UNSPEAKABLE.test(req.context.description.join(" ")))
|
|
3680
|
-
problems.push("urgency is 'call'/'banner' but the description has code fences/newlines-in-chunk \u2014 rewrite in spoken register (it will be read aloud)");
|
|
3681
|
-
}
|
|
3682
|
-
for (const o of req.options ?? []) {
|
|
3683
|
-
if (o.label.length > OPTION_MAX)
|
|
3684
|
-
problems.push(`option label "${o.label.slice(0, 40)}\u2026" is ${o.label.length} chars \u2014 labels must read at a glance (\u2264${OPTION_MAX}); move detail into the description`);
|
|
3685
|
-
}
|
|
3686
|
-
return problems;
|
|
3687
|
-
}
|
|
3688
3746
|
|
|
3689
3747
|
export {
|
|
3690
3748
|
BACKEND_URL,
|
|
3691
3749
|
reach,
|
|
3750
|
+
lintNotify,
|
|
3692
3751
|
NotifyRequestSchema,
|
|
3693
3752
|
SetTaskStateSchema,
|
|
3694
3753
|
ScheduleCallbackSchema,
|
|
@@ -3712,10 +3771,10 @@ export {
|
|
|
3712
3771
|
submitNotification,
|
|
3713
3772
|
awaitReply,
|
|
3714
3773
|
getThread,
|
|
3774
|
+
searchThreads,
|
|
3715
3775
|
checkReplies,
|
|
3716
3776
|
setTaskState,
|
|
3717
3777
|
registerDelivery,
|
|
3718
3778
|
scheduleCallback,
|
|
3719
|
-
handoff
|
|
3720
|
-
lintNotify
|
|
3779
|
+
handoff
|
|
3721
3780
|
};
|
|
@@ -27,7 +27,7 @@ var TransformSchema = z.enum([
|
|
|
27
27
|
var OptionSchema = z.object({
|
|
28
28
|
id: z.string(),
|
|
29
29
|
label: z.string(),
|
|
30
|
-
// .describe() flows into the MCP
|
|
30
|
+
// .describe() flows into the MCP contact JSON schema (zodToJsonSchema), so
|
|
31
31
|
// the constraints below are what an agent reads when deciding to use these.
|
|
32
32
|
html: z.string().max(16384).describe(
|
|
33
33
|
"Optional sandboxed HTML/CSS preview for a visual 'pick one' (shown in the option card). Untrusted-sandboxed: NO JavaScript, NO external network or images \u2014 inline CSS and data: URIs only; <=16KB. Use for layout/CSS mockups, tables, diffs. For a hosted image use `image` instead."
|
|
@@ -138,6 +138,13 @@ var NotifyRequestSchema = z.object({
|
|
|
138
138
|
waiting: z.enum(["none", "soft", "hard"]).optional().describe(
|
|
139
139
|
"With `ask`: what happens to your work while you wait. 'none' = you're just informing the user. 'soft' = you'd like an answer but can keep working. 'hard' = you are stopped until they answer (reaches them urgently and escalates to a real phone call if unanswered). Replaces urgencyHint + blocking \u2014 send this one field."
|
|
140
140
|
),
|
|
141
|
+
/** #575: a RELAY of the user's explicitly stated preference, never the agent's
|
|
142
|
+
* choice. Outranks waiting in both directions: 'call' rings even for a
|
|
143
|
+
* waiting:'none' "call me when it's done"; 'message' never rings even for
|
|
144
|
+
* waiting:'hard'. */
|
|
145
|
+
channel: z.enum(["call", "message"]).optional().describe(
|
|
146
|
+
"Only if the user explicitly said how to reach them \u2014 'call me' \u2192 'call', 'just message/text me' \u2192 'message'. Omit otherwise; Paigy picks."
|
|
147
|
+
),
|
|
141
148
|
confirmStyle: z.enum(["yesno", "approve"]).default("yesno").describe(
|
|
142
149
|
"Labels for a select:'confirm' paige \u2014 'yesno' (Yes/No) or 'approve' (Approve/Deny). Ignored unless select is 'confirm'."
|
|
143
150
|
),
|
|
@@ -190,17 +197,17 @@ var TurnSchema = z.object({
|
|
|
190
197
|
reply: z.string()
|
|
191
198
|
});
|
|
192
199
|
var UserAnswerSchema = z.discriminatedUnion("kind", [
|
|
193
|
-
z.object({ kind: z.literal("option"), optionId: z.string() }),
|
|
200
|
+
z.object({ kind: z.literal("option"), optionId: z.string(), label: z.string().optional() }),
|
|
194
201
|
z.object({ kind: z.literal("text"), text: z.string() }),
|
|
195
202
|
z.object({ kind: z.literal("ignored") }),
|
|
196
|
-
z.object({ kind: z.literal("multi"), optionIds: z.array(z.string()) }),
|
|
197
|
-
z.object({ kind: z.literal("ranked"), optionIds: z.array(z.string()) }),
|
|
203
|
+
z.object({ kind: z.literal("multi"), optionIds: z.array(z.string()), labels: z.array(z.string()).optional() }),
|
|
204
|
+
z.object({ kind: z.literal("ranked"), optionIds: z.array(z.string()), labels: z.array(z.string()).optional() }),
|
|
198
205
|
z.object({ kind: z.literal("clarify"), chunks: z.array(z.string()).min(1) }),
|
|
199
206
|
z.object({ kind: z.literal("confirm"), approved: z.boolean() }),
|
|
200
207
|
z.object({ kind: z.literal("turns"), turns: z.array(TurnSchema).min(1) })
|
|
201
208
|
]);
|
|
202
209
|
var IntentSchema = z.object({
|
|
203
|
-
kind: z.enum(["defer", "delegate", "channel"]),
|
|
210
|
+
kind: z.enum(["defer", "delegate", "channel", "question"]),
|
|
204
211
|
detail: z.string(),
|
|
205
212
|
/** Landed defer (#397): the MCP parses common spoken forms ("in 20 minutes",
|
|
206
213
|
* "after lunch") against the agent machine's clock — the user's — and attaches
|
|
@@ -238,11 +245,20 @@ var AwaitItemSchema = z.discriminatedUnion("type", [
|
|
|
238
245
|
/** Seconds until remindAt, server-computed — pass straight to ScheduleWakeup. */
|
|
239
246
|
remindInSeconds: z.number()
|
|
240
247
|
}),
|
|
248
|
+
/** The awaited ask was REPLACED by a newer notification on its thread (e.g. a
|
|
249
|
+
* post-feedback revision, #633) — the user will never answer this id. Stop
|
|
250
|
+
* awaiting it; the live ask is the thread's newest turn (await that one, or
|
|
251
|
+
* re-orient via get_thread / check_replies). */
|
|
252
|
+
z.object({
|
|
253
|
+
type: z.literal("superseded"),
|
|
254
|
+
threadId: z.string(),
|
|
255
|
+
notificationId: z.string()
|
|
256
|
+
}),
|
|
241
257
|
z.object({ type: z.literal("idle") })
|
|
242
258
|
]);
|
|
243
259
|
var CallbackTriggerSchema = z.enum(["on_done", "on_blocked", "scheduled"]);
|
|
244
260
|
var ScheduleCallbackSchema = z.object({
|
|
245
|
-
threadId: z.string().describe("The thread to call back on (from a prior
|
|
261
|
+
threadId: z.string().describe("The thread to call back on (from a prior contact / reply / request)."),
|
|
246
262
|
trigger: CallbackTriggerSchema,
|
|
247
263
|
dueInSeconds: z.number().int().positive().optional().describe("For 'scheduled' only: how many seconds from now to fire."),
|
|
248
264
|
note: z.string().optional().describe("What to tell the user when you follow up.")
|
|
@@ -270,7 +286,7 @@ var PendingRepliesSchema = z.object({
|
|
|
270
286
|
z.object({ threadId: z.string(), notificationId: z.string(), createdAt: z.string() })
|
|
271
287
|
),
|
|
272
288
|
/** User-initiated requests addressed to this agent; act on them and reply via
|
|
273
|
-
*
|
|
289
|
+
* contact on the same threadId. Keeps reappearing until you call
|
|
274
290
|
* set_task_state on its notificationId. */
|
|
275
291
|
requests: z.array(
|
|
276
292
|
z.object({
|
|
@@ -285,7 +301,7 @@ var PendingRepliesSchema = z.object({
|
|
|
285
301
|
),
|
|
286
302
|
/** Callbacks you owe the user that are now DUE (you said you'd follow up when done,
|
|
287
303
|
* if blocked, or at a time that has passed). Re-surfaced every sweep until you
|
|
288
|
-
* fulfill one by calling
|
|
304
|
+
* fulfill one by calling contact on its threadId. */
|
|
289
305
|
owedCallbacks: z.array(
|
|
290
306
|
z.object({ threadId: z.string(), trigger: CallbackTriggerSchema, note: z.string() })
|
|
291
307
|
),
|
|
@@ -294,6 +310,26 @@ var PendingRepliesSchema = z.object({
|
|
|
294
310
|
* went idle. Report a real state (set_task_state) or continue the work. */
|
|
295
311
|
stalled: z.array(
|
|
296
312
|
z.object({ threadId: z.string(), notificationId: z.string(), title: z.string().nullable(), startedAt: z.string() })
|
|
313
|
+
),
|
|
314
|
+
/** The queue rail (#614, pending/design.md): the same replies + requests, grouped by
|
|
315
|
+
* thread and ordered oldest-thread-first, so you work ONE thread at a time — fold all of
|
|
316
|
+
* a thread's `items` into a single turn rather than interleaving threads. `busy` = the
|
|
317
|
+
* thread already has a turn in progress (younger than the stall cutoff); let it finish and
|
|
318
|
+
* ride the next turn. `items` are that thread's replies/requests in arrival order; the
|
|
319
|
+
* full payload for each is in the flat `replies`/`requests` arrays (matched by
|
|
320
|
+
* notificationId). Derived, never stored — a crashed agent recomputes it exactly. */
|
|
321
|
+
threads: z.array(
|
|
322
|
+
z.object({
|
|
323
|
+
threadId: z.string(),
|
|
324
|
+
busy: z.boolean(),
|
|
325
|
+
items: z.array(
|
|
326
|
+
z.object({
|
|
327
|
+
kind: z.enum(["reply", "request"]),
|
|
328
|
+
notificationId: z.string(),
|
|
329
|
+
at: z.string()
|
|
330
|
+
})
|
|
331
|
+
)
|
|
332
|
+
})
|
|
297
333
|
)
|
|
298
334
|
});
|
|
299
335
|
var NotifyResponseSchema = z.object({
|
|
@@ -429,6 +465,15 @@ var UserSettingsSchema = z.object({
|
|
|
429
465
|
* must not silently reset this privacy choice. Absent = leave unchanged on
|
|
430
466
|
* write, 'hosted' on read (see store.ts). */
|
|
431
467
|
voiceMode: z.enum(["hosted", "on_device"]).optional(),
|
|
468
|
+
/** Per-user ring budget (#603): calls per rolling day before further calls
|
|
469
|
+
* degrade to banner. Absent = the global default (25). A number, never a
|
|
470
|
+
* bypass — every account keeps a ceiling. No UI; set per user for testing. */
|
|
471
|
+
callBudget: z.number().int().min(1).max(500).optional(),
|
|
472
|
+
/** Per-user voice-call tuning (#318): raw knobs forwarded to the call bot's
|
|
473
|
+
* payload['tuning'] (e.g. { silence_s: 3.5 } — a longer pause window for a
|
|
474
|
+
* slower speaker). No API-side semantics; the bot resolves each key with its
|
|
475
|
+
* own defaults. Set per user (no UI yet); absent = bot defaults. */
|
|
476
|
+
voiceTuning: z.record(z.string(), z.union([z.number(), z.string()])).optional(),
|
|
432
477
|
/** Opt-in to real-phone (PSTN) calls when the app can't ring. Optional, not
|
|
433
478
|
* defaulted — an older client PATCHing the full object must not clobber it. */
|
|
434
479
|
pstnCalls: z.boolean().optional(),
|
|
@@ -498,7 +543,12 @@ var HandoffSchema = z.object({
|
|
|
498
543
|
notes: z.array(z.string().min(1)).min(1),
|
|
499
544
|
/** A sibling connection to dispatch directly to (token id or agent nickname). Same-account
|
|
500
545
|
* only; omit to leave the thread for the user to hand off in the app. */
|
|
501
|
-
target: z.string().optional()
|
|
546
|
+
target: z.string().optional(),
|
|
547
|
+
/** Write the note as a RECAP (kind:'recap', #617): a summary turn that supersedes the
|
|
548
|
+
* thread's earlier turns for rehydration — get_thread returns the latest recap + only
|
|
549
|
+
* the turns after it. Handoff-to-a-successor and handoff-to-yourself-later are the
|
|
550
|
+
* same primitive; a recap is one whose audience includes you. */
|
|
551
|
+
recap: z.boolean().optional()
|
|
502
552
|
});
|
|
503
553
|
var DeliveryModeSchema = z.enum(["poll", "self_hosted"]);
|
|
504
554
|
var WAKE_EVENT = "wake";
|
package/dist/index.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
HandoffSchema
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-LUY5MXDM.js";
|
|
5
5
|
import {
|
|
6
6
|
PAIGY_TOOL_IDS,
|
|
7
7
|
autoConfigureClients,
|
|
8
8
|
claudeInstallHint,
|
|
9
9
|
enablePaigyTools
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-FEAIZ6DA.js";
|
|
11
11
|
import {
|
|
12
12
|
clearSurface,
|
|
13
13
|
writeSurface
|
|
@@ -34,11 +34,12 @@ import {
|
|
|
34
34
|
saveKeyFile,
|
|
35
35
|
saveToken,
|
|
36
36
|
scheduleCallback,
|
|
37
|
+
searchThreads,
|
|
37
38
|
setTaskState,
|
|
38
39
|
sleep,
|
|
39
40
|
startE2ee,
|
|
40
41
|
submitNotification
|
|
41
|
-
} from "./chunk-
|
|
42
|
+
} from "./chunk-2374V6WK.js";
|
|
42
43
|
|
|
43
44
|
// src/index.ts
|
|
44
45
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
@@ -105,6 +106,11 @@ var CONTACT_SCHEMA = {
|
|
|
105
106
|
},
|
|
106
107
|
description: "The choices the user picks from, when you have them."
|
|
107
108
|
},
|
|
109
|
+
channel: {
|
|
110
|
+
type: "string",
|
|
111
|
+
enum: ["call", "message"],
|
|
112
|
+
description: "Only if the user explicitly said how to reach them \u2014 'call me' \u2192 'call', 'just message/text me' \u2192 'message'. Omit otherwise; Paigy picks."
|
|
113
|
+
},
|
|
108
114
|
threadId: {
|
|
109
115
|
type: "string",
|
|
110
116
|
description: "To continue an earlier conversation, pass the threadId a previous contact or reply returned. Omit to start a new one."
|
|
@@ -158,6 +164,21 @@ async function resolvePairing(deviceCode, capMs, pollMs = 2e3) {
|
|
|
158
164
|
return { kind: "pending" };
|
|
159
165
|
}
|
|
160
166
|
var bg = null;
|
|
167
|
+
var started = null;
|
|
168
|
+
async function startPairing(agent) {
|
|
169
|
+
if (started) return started;
|
|
170
|
+
const { keyFile, offer } = startE2ee();
|
|
171
|
+
const code = await requestCode(agent, offer);
|
|
172
|
+
started = {
|
|
173
|
+
verificationUri: code.verification_uri_complete,
|
|
174
|
+
userCode: code.user_code,
|
|
175
|
+
deviceCode: code.device_code,
|
|
176
|
+
expiresIn: code.expires_in
|
|
177
|
+
};
|
|
178
|
+
saveKeyFile({ ...keyFile, userCode: code.user_code });
|
|
179
|
+
startBackgroundPair(code.device_code, code.expires_in * 1e3);
|
|
180
|
+
return started;
|
|
181
|
+
}
|
|
161
182
|
function startBackgroundPair(deviceCode, budgetMs) {
|
|
162
183
|
const promise = resolvePairing(deviceCode, budgetMs).catch((e) => ({ kind: "error", message: e.message })).then((o) => {
|
|
163
184
|
if (bg?.deviceCode === deviceCode) bg.settled = o;
|
|
@@ -170,27 +191,34 @@ async function joinBackgroundPair(deviceCode, capMs) {
|
|
|
170
191
|
if (bg.settled) {
|
|
171
192
|
const s = bg.settled;
|
|
172
193
|
bg = null;
|
|
194
|
+
if (s.kind === "paired" || s.kind === "error") started = null;
|
|
173
195
|
return s;
|
|
174
196
|
}
|
|
175
197
|
const TIMEOUT = /* @__PURE__ */ Symbol("timeout");
|
|
176
198
|
const raced = await Promise.race([bg.promise, sleep(capMs).then(() => TIMEOUT)]);
|
|
177
199
|
if (raced !== TIMEOUT) {
|
|
178
200
|
bg = null;
|
|
201
|
+
if (raced.kind === "paired" || raced.kind === "error") started = null;
|
|
179
202
|
return raced;
|
|
180
203
|
}
|
|
181
204
|
if (bg?.settled) {
|
|
182
205
|
const s = bg.settled;
|
|
183
206
|
bg = null;
|
|
207
|
+
if (s.kind === "paired" || s.kind === "error") started = null;
|
|
184
208
|
return s;
|
|
185
209
|
}
|
|
186
210
|
return { kind: "pending" };
|
|
187
211
|
}
|
|
188
212
|
function cancelBackgroundPair() {
|
|
189
213
|
bg = null;
|
|
214
|
+
started = null;
|
|
215
|
+
}
|
|
216
|
+
function clearPairing(deviceCode) {
|
|
217
|
+
if (deviceCode && started?.deviceCode !== deviceCode) return;
|
|
218
|
+
cancelBackgroundPair();
|
|
190
219
|
}
|
|
191
220
|
|
|
192
221
|
// src/index.ts
|
|
193
|
-
var ONBOARD_MSG = "Not paired with Paigy yet \u2014 call the `pair` tool to connect this agent (it returns an approval link to show the user), then retry. Manual fallback: `npx -y -p @paigy/mcp paigy-mcp-onboard`.";
|
|
194
222
|
var AwaitReplySchema = z.object({
|
|
195
223
|
notificationId: z.string().describe("The notificationId returned by contact \u2014 waits for the user's reply to THIS notification only.")
|
|
196
224
|
});
|
|
@@ -200,6 +228,9 @@ var PairSchema = z.object({
|
|
|
200
228
|
var GetThreadSchema = z.object({
|
|
201
229
|
threadId: z.string().describe("The thread to read \u2014 from a reply, request, or past notification.")
|
|
202
230
|
});
|
|
231
|
+
var SearchThreadsSchema = z.object({
|
|
232
|
+
q: z.string().describe("What to look for \u2014 plain words or a phrase (e.g. 'the livekit timeout', 'deploy to prod').")
|
|
233
|
+
});
|
|
203
234
|
var EnableToolsSchema = z.object({
|
|
204
235
|
scope: z.enum(["user", "project"]).optional().describe("'user' (default) allowlists Paigy's tools for every project (~/.claude/settings.json); 'project' scopes them to this repo (.claude/settings.json).")
|
|
205
236
|
});
|
|
@@ -294,11 +325,33 @@ function renderPairOutcome(outcome, device_code) {
|
|
|
294
325
|
};
|
|
295
326
|
}
|
|
296
327
|
}
|
|
328
|
+
function pairStartResult(start) {
|
|
329
|
+
writeSurface("pairing code", start.userCode, start.expiresIn);
|
|
330
|
+
const qr = qrcode(0, "M");
|
|
331
|
+
qr.addData(start.verificationUri);
|
|
332
|
+
qr.make();
|
|
333
|
+
return { content: [
|
|
334
|
+
{ type: "text", text: `PAIRING CODE: ${start.userCode}
|
|
335
|
+
Enter it in the Paigy app: Inbox \u2192 Add a new agent.` },
|
|
336
|
+
{ type: "text", text: JSON.stringify({
|
|
337
|
+
status: "awaiting_approval",
|
|
338
|
+
verification_uri_complete: start.verificationUri,
|
|
339
|
+
user_code: start.userCode,
|
|
340
|
+
device_code: start.deviceCode,
|
|
341
|
+
expires_in: start.expiresIn,
|
|
342
|
+
qr: qr.createASCII(1, 2),
|
|
343
|
+
user_message: `# ${start.userCode}
|
|
344
|
+
|
|
345
|
+
Enter it in the Paigy app (Inbox \u2192 Add a new agent).`,
|
|
346
|
+
message: "REQUIRED: print user_message for the user, then call pair again with device_code to collect approval. Do not open a browser."
|
|
347
|
+
}) }
|
|
348
|
+
] };
|
|
349
|
+
}
|
|
297
350
|
var server = new Server(
|
|
298
351
|
{ name: "paigy", version: "0.0.0" },
|
|
299
352
|
{
|
|
300
353
|
capabilities: { tools: {} },
|
|
301
|
-
instructions: "On startup, call check_replies once to pick up any replies or pending work you missed while away. A check_replies request whose threadId you don't recognize, or one carrying a contextThreadId, means the user is resuming or seeding a past conversation \u2014 call get_thread on it FIRST and treat the transcript as prior conversation, not new input. To wait for the answer to something you just asked, call await_reply with that notificationId \u2014 it's scoped to that one notification, so it never returns replies meant for other notifications. Use check_replies again only when re-booting or after waiting a long time on something else. Never end a turn that still needs the user without contact + await_reply. When you need a decision or input, MATCH the answer shape to the question \u2014 don't default everything to free text, and don't reflexively make everything yes/no. Pick the best tool for the job: yes/no \u2192 select:'confirm'; approve/deny an action \u2192 select:'confirm' + confirmStyle:'approve'; pick one of several \u2192 options + select:'one'; pick several / a subset \u2192 options + select:'many'; rank or prioritize \u2192 options + select:'rank'. Reserve select:'text' (free-form reply only) for plain updates and answers that genuinely can't be structured (the user can always add free text on top of any shape). On a { kind: 'clarify' } reply, see contact's own description for how to respond. When
|
|
354
|
+
instructions: "On startup, call check_replies once to pick up any replies or pending work you missed while away. A check_replies request whose threadId you don't recognize, or one carrying a contextThreadId, means the user is resuming or seeding a past conversation \u2014 call get_thread on it FIRST and treat the transcript as prior conversation, not new input. To wait for the answer to something you just asked, call await_reply with that notificationId \u2014 it's scoped to that one notification, so it never returns replies meant for other notifications. Use check_replies again only when re-booting or after waiting a long time on something else. Never end a turn that still needs the user without contact + await_reply. When you need a decision or input, MATCH the answer shape to the question \u2014 don't default everything to free text, and don't reflexively make everything yes/no. Pick the best tool for the job: yes/no \u2192 select:'confirm'; approve/deny an action \u2192 select:'confirm' + confirmStyle:'approve'; pick one of several \u2192 options + select:'one'; pick several / a subset \u2192 options + select:'many'; rank or prioritize \u2192 options + select:'rank'. Reserve select:'text' (free-form reply only) for plain updates and answers that genuinely can't be structured (the user can always add free text on top of any shape). On a { kind: 'clarify' } reply, see contact's own description for how to respond. When you send waiting:'hard' (or the user asked you to call), remember the ask may be spoken aloud \u2014 write it short and conversational, and name things instead of using IDs (e.g. 'the pull request about the agents page', not 'PR #235'). When the user asks you to follow up later \u2014 when you're done, if you're blocked, or at a set time \u2014 record it with schedule_callback so you don't drop it if you go idle. If you're about to start a genuinely long-running or blocking piece of work \u2014 one where the user would otherwise sit and wait \u2014 mention ONCE, in passing, that you can reach them when it's done or if you hit a blocker, instead of them needing to babysit the terminal. Don't offer this for quick tasks, and don't repeat the offer if they've already said yes or no earlier in the conversation. NEVER go quietly idle while something might still be pending for you: whenever you end a turn with any Paigy notification unanswered (or any chance the user replied through the app while you worked), schedule your own ~2-minute wake-up (harness ScheduleWakeup or equivalent) and call check_replies when it fires; if still nothing, re-schedule and keep looping until resolved or the user says stop. For legibility, always use this exact wording \u2014 reason: 'Paigy idle check \u2014 waiting on <thing>', wake-up prompt: 'Paigy idle check: call check_replies and engage with anything unacknowledged; if idle, re-schedule (~2min).' \u2014 so the user can recognize every idle check at a glance. This self-polling in your own live session (full context intact) is the PRIMARY mechanism; the plugin's Stop hooks are only the dead-session safety net."
|
|
302
355
|
}
|
|
303
356
|
);
|
|
304
357
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
@@ -328,19 +381,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
328
381
|
},
|
|
329
382
|
{
|
|
330
383
|
name: "await_reply",
|
|
331
|
-
description: "Wait for the user's reply to a specific notification you sent (pass the notificationId from contact). This is how you wait for your answer in-context. Polls ~5 min; returns { type:'reply', answer } when they respond, { type:'remind', remindInSeconds } on snooze (ScheduleWakeup then await_reply again), or { type:'idle' } (timed out this window, no answer yet). On idle, if this is genuinely still blocking you and you have nothing else useful to do meanwhile, just call await_reply again immediately \u2014 keep looping. This is how you actually deliver on the point of calling: the user steps away for a while and comes back to find you'd already continued the moment they answered, not idle waiting to be checked on. Don't give up after one window. Only stop looping to do other work (and check back later), or after an unreasonably long stretch (tens of minutes to hours) worth telling the user about instead. Scoped to that one notification \u2014 it NEVER returns replies meant for other notifications, so concurrent contact calls don't cross. A CALL answer can come back as {kind:'turns', turns:[{prompt,reply}]} \u2014 the ordered log of that call. Read turns[0].reply as the user's main instruction. Usually that's the only turn; if there are more (e.g. an end-of-call 'call me back when it's done / I have a blocking question'), read each one in order as a further follow-up instruction, not a single combined one. If they asked for a callback, re-engage in the SAME thread (contact with the reply's threadId) when the task is done or you hit a blocker \u2014
|
|
384
|
+
description: "Wait for the user's reply to a specific notification you sent (pass the notificationId from contact). This is how you wait for your answer in-context. Polls ~5 min; returns { type:'reply', answer } when they respond, { type:'remind', remindInSeconds } on snooze (ScheduleWakeup then await_reply again), or { type:'idle' } (timed out this window, no answer yet). On idle, if this is genuinely still blocking you and you have nothing else useful to do meanwhile, just call await_reply again immediately \u2014 keep looping. This is how you actually deliver on the point of calling: the user steps away for a while and comes back to find you'd already continued the moment they answered, not idle waiting to be checked on. Don't give up after one window. Only stop looping to do other work (and check back later), or after an unreasonably long stretch (tens of minutes to hours) worth telling the user about instead. Scoped to that one notification \u2014 it NEVER returns replies meant for other notifications, so concurrent contact calls don't cross. A CALL answer can come back as {kind:'turns', turns:[{prompt,reply}]} \u2014 the ordered log of that call. Read turns[0].reply as the user's main instruction. Usually that's the only turn; if there are more (e.g. an end-of-call 'call me back when it's done / I have a blocking question'), read each one in order as a further follow-up instruction, not a single combined one. If they asked for a callback, re-engage in the SAME thread (contact with the reply's threadId) when the task is done or you hit a blocker \u2014 waiting:'hard' for a blocker, waiting:'none' for done. Paigy has no scheduler; the callback is yours to send (use ScheduleWakeup/cron for timing). A call-mapped answer may carry `intents` \u2014 next steps the user attached, each { kind, detail } with detail quoting their words. ACT on them, don't just read them: 'defer' (\"call me after lunch\") \u2192 register it NOW with schedule_callback \u2014 when the intent carries `dueInSeconds` (Paigy pre-parsed the spoken time against the user's clock) pass it straight through; otherwise derive it from the detail yourself \u2014 then follow up on the same thread; 'delegate' (\"you pick\") \u2192 make the call yourself and tell them what you chose; 'channel' (\"text me next time\") \u2192 honor it on your next contact (channel:'message'); 'question' (an open question aimed back at you that the call couldn't answer) \u2192 you OWE them the answer \u2014 work it out and follow up on the same thread without being asked, the call deliberately skipped \"should I call you back?\" because the follow-up is implied. `transcript` is the user's raw words behind a shaped answer \u2014 read it for hedges and conditions (\"yes, IF tests pass\") before acting. If your ask declared `points`, the reply carries `covered` \u2014 the points actually addressed. Compare against what you declared: a missing point is STILL unanswered \u2014 re-ask it (contact on the same threadId) or proceed knowingly partial; never treat a partial answer as complete.",
|
|
332
385
|
inputSchema: json(AwaitReplySchema)
|
|
333
386
|
},
|
|
334
387
|
{
|
|
335
388
|
name: "check_replies",
|
|
336
|
-
description: "The catch-up sweep for everything outstanding \u2014 a PURE read, takes no arguments, safe to call as often as you like: nothing here is consumed by reading it. Returns `replies` (answers to notifications you sent), your still-pending notifications, and `requests` \u2014 requests the user started toward you (each { notificationId, threadId, text }). Each keeps reappearing on every call until you actually engage with it: call set_task_state on its notificationId, which is what claims/acknowledges it \u2014 a human-initiated reply or request must never be silently dropped just because you read the list without acting. Use check_replies when booting up / starting a session, or when you've been waiting a long time on something else. To wait on an answer to a contact call you just made, use await_reply instead. Also returns owedCallbacks: callbacks now due that you promised \u2014 fulfill each with contact on its threadId. Also returns `stalled`: work (either direction) you reported in_progress via set_task_state a while ago and never reported completed \u2014 likely left half-done by this session or a prior one that crashed or went idle. For each, either continue the work and report a real state, or investigate why it stalled. Replies may carry `intents`/`transcript`/`covered` (call-mapped answers) \u2014 handle intents exactly as await_reply's description says (defer \u2192 schedule_callback now; delegate \u2192 decide and say so; channel \u2192 honor next contact), and treat a `covered` list missing one of your declared points as that part still unanswered.",
|
|
389
|
+
description: "The catch-up sweep for everything outstanding \u2014 a PURE read, takes no arguments, safe to call as often as you like: nothing here is consumed by reading it. Returns `replies` (answers to notifications you sent), your still-pending notifications, and `requests` \u2014 requests the user started toward you (each { notificationId, threadId, text }). Each keeps reappearing on every call until you actually engage with it: call set_task_state on its notificationId, which is what claims/acknowledges it \u2014 a human-initiated reply or request must never be silently dropped just because you read the list without acting. Also returns `threads` \u2014 the SAME replies + requests grouped by conversation, oldest thread first, each with a `busy` flag and its `items` in arrival order. WORK ONE THREAD AT A TIME: take the oldest thread whose `busy` is false, handle ALL of its items together in a single turn (one set_task_state), then go to the next \u2014 don't interleave threads item-by-item. A `busy` thread already has a turn in progress; leave it and let its new items ride the next turn. Use check_replies when booting up / starting a session, or when you've been waiting a long time on something else. To wait on an answer to a contact call you just made, use await_reply instead. Also returns owedCallbacks: callbacks now due that you promised \u2014 fulfill each with contact on its threadId. Also returns `stalled`: work (either direction) you reported in_progress via set_task_state a while ago and never reported completed \u2014 likely left half-done by this session or a prior one that crashed or went idle. For each, either continue the work and report a real state, or investigate why it stalled. Replies may carry `intents`/`transcript`/`covered` (call-mapped answers) \u2014 handle intents exactly as await_reply's description says (defer \u2192 schedule_callback now; delegate \u2192 decide and say so; channel \u2192 honor next contact), and treat a `covered` list missing one of your declared points as that part still unanswered.",
|
|
337
390
|
inputSchema: json(z.object({}))
|
|
338
391
|
},
|
|
339
392
|
{
|
|
340
393
|
name: "get_thread",
|
|
341
|
-
description: "The chronological transcript of one Paigy conversation thread \u2014 every past ask, answer, and user request on it. Call this to REHYDRATE when you're resuming or being seeded: a check_replies request whose threadId you don't recognize means the user is continuing an old conversation with you, and one carrying a contextThreadId means they want a past conversation (possibly with a DIFFERENT agent) as your starting context \u2014 in both cases call get_thread FIRST and read the turns as prior conversation you were part of, not as new input. Turns: { role:'agent', title, description[], answer }
|
|
394
|
+
description: "The chronological transcript of one Paigy conversation thread \u2014 every past ask, answer, and user request on it. Call this to REHYDRATE when you're resuming or being seeded: a check_replies request whose threadId you don't recognize means the user is continuing an old conversation with you, and one carrying a contextThreadId means they want a past conversation (possibly with a DIFFERENT agent) as your starting context \u2014 in both cases call get_thread FIRST and read the turns as prior conversation you were part of, not as new input. Turns: { role:'agent', title, description[], answer }, { role:'user', text }, and context turns { role:'handoff'|'recap', title, description[] } \u2014 a handoff is a predecessor's brief for you; a recap SUMMARIZES everything before it (the transcript starts at the latest recap, so treat it as the base and the turns after it as what happened since). Oldest first, capped at the most recent 30.",
|
|
342
395
|
inputSchema: json(GetThreadSchema)
|
|
343
396
|
},
|
|
397
|
+
{
|
|
398
|
+
name: "search_threads",
|
|
399
|
+
description: `Search your PAST conversations before asking \u2014 "have we discussed this before?". Full-text over your own threads (the asks you sent + the user's answers); returns ranked threads with highlighted snippets, NOT rows: { hits: [{ threadId, at, agentLabel, matches: [{ notificationId, role, snippet }] }] }. The loop this exists for: search first \u2192 get_thread the best hit to rehydrate it \u2192 THEN continue or contact, so you answer with receipts ("last week you said ship it") instead of re-asking. Read-only, safe to call anytime; scoped to your own account's threads.`,
|
|
400
|
+
inputSchema: json(SearchThreadsSchema)
|
|
401
|
+
},
|
|
344
402
|
{
|
|
345
403
|
name: "set_task_state",
|
|
346
404
|
description: "Report progress on the follow-up work behind ANY notification you own \u2014 a user-initiated request (from check_replies), or your OWN contact question once await_reply/check_replies returns its answer and you start acting on it. Pass that notificationId. THIS is what actually claims/acknowledges a reply or request \u2014 check_replies is a pure read that never consumes anything on its own, so call this as soon as you start engaging with something it returned; otherwise that same item just keeps reappearing forever. States: in_progress (you started working), completed (done), or needs_input (you need more from the user \u2014 usually paired with a contact carrying parentId = the same notificationId you're reporting on). Calling this reliably is also what lets a future session's check_replies surface `stalled` work you (or a crashed/idle prior session) left at in_progress without ever reporting completed.",
|
|
@@ -353,7 +411,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
353
411
|
},
|
|
354
412
|
{
|
|
355
413
|
name: "handoff",
|
|
356
|
-
description: "Deposit your working context for a SUCCESSOR agent \u2014 what you did, what's left, links, gotchas \u2014 as one note on a thread ({ title, notes[] }). This does NOT ring the user or enter their inbox: it's context, not a question. The successor reads it back with get_thread. Pass `target` (a sibling connection's token id or agent name, SAME account only) to hand off DIRECTLY to that agent \u2014 the note is dispatched to it as a request it picks up. Omit `target` to leave the thread for the user to hand off to an agent themselves in the app. Pass `threadId` to land the handoff on an existing conversation; omit it to mint a fresh thread. Returns { threadId }.",
|
|
414
|
+
description: "Deposit your working context for a SUCCESSOR agent \u2014 what you did, what's left, links, gotchas \u2014 as one note on a thread ({ title, notes[] }). This does NOT ring the user or enter their inbox: it's context, not a question. The successor reads it back with get_thread. Pass `target` (a sibling connection's token id or agent name, SAME account only) to hand off DIRECTLY to that agent \u2014 the note is dispatched to it as a request it picks up. Omit `target` to leave the thread for the user to hand off to an agent themselves in the app. Pass `threadId` to land the handoff on an existing conversation; omit it to mint a fresh thread. Returns { threadId }. Pass recap:true when the note SUMMARIZES the thread so far (for a successor OR for your own later session): a recap resets the rehydration window \u2014 get_thread returns the latest recap + only the turns after it. Write one whenever a thread has grown long and you're pausing, handing off, or nearing your context limit.",
|
|
357
415
|
inputSchema: json(HandoffSchema)
|
|
358
416
|
}
|
|
359
417
|
];
|
|
@@ -363,7 +421,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
363
421
|
try {
|
|
364
422
|
return await handleTool(request);
|
|
365
423
|
} catch (e) {
|
|
366
|
-
if (e instanceof UnpairedError)
|
|
424
|
+
if (e instanceof UnpairedError) return pairStartResult(await startPairing(suggestedAgentName()));
|
|
367
425
|
throw e;
|
|
368
426
|
}
|
|
369
427
|
});
|
|
@@ -391,35 +449,7 @@ async function handleTool(request) {
|
|
|
391
449
|
case "pair": {
|
|
392
450
|
const { device_code } = PairSchema.parse(request.params.arguments ?? {});
|
|
393
451
|
if (!device_code) {
|
|
394
|
-
|
|
395
|
-
const code = await requestCode(suggestedAgentName(), offer);
|
|
396
|
-
saveKeyFile({ ...keyFile, userCode: code.user_code });
|
|
397
|
-
writeSurface("pairing code", code.user_code, code.expires_in);
|
|
398
|
-
startBackgroundPair(code.device_code, code.expires_in * 1e3);
|
|
399
|
-
const qr = qrcode(0, "M");
|
|
400
|
-
qr.addData(code.verification_uri_complete);
|
|
401
|
-
qr.make();
|
|
402
|
-
return {
|
|
403
|
-
content: [
|
|
404
|
-
{ type: "text", text: `PAIRING CODE: ${code.user_code}
|
|
405
|
-
Enter it in the Paigy app: Inbox \u2192 Add a new agent.` },
|
|
406
|
-
{
|
|
407
|
-
type: "text",
|
|
408
|
-
text: JSON.stringify({
|
|
409
|
-
status: "awaiting_approval",
|
|
410
|
-
verification_uri_complete: code.verification_uri_complete,
|
|
411
|
-
user_code: code.user_code,
|
|
412
|
-
device_code: code.device_code,
|
|
413
|
-
expires_in: code.expires_in,
|
|
414
|
-
qr: qr.createASCII(1, 2),
|
|
415
|
-
user_message: `# ${code.user_code}
|
|
416
|
-
|
|
417
|
-
Enter it in the Paigy app (Inbox \u2192 Add a new agent).`,
|
|
418
|
-
message: `REQUIRED: You MUST immediately print \`user_message\` (the bare code) as a text message to the user, AND in that same turn call pair again with this device_code to poll. Do NOT end your turn without printing the code text, or it will be hidden inside the tool output. Because approval is already polling in the background, this call will wait for the user to approve and then return the token. Do NOT open a browser. The user may prefer scanning \`qr\` (print it verbatim in a fenced code block on request).`
|
|
419
|
-
})
|
|
420
|
-
}
|
|
421
|
-
]
|
|
422
|
-
};
|
|
452
|
+
return pairStartResult(await startPairing(suggestedAgentName()));
|
|
423
453
|
}
|
|
424
454
|
const capMs = 9e4;
|
|
425
455
|
const outcome = await joinBackgroundPair(device_code, capMs) ?? await resolvePairing(device_code, capMs);
|
|
@@ -437,7 +467,7 @@ Enter it in the Paigy app (Inbox \u2192 Add a new agent).`,
|
|
|
437
467
|
}
|
|
438
468
|
const removed = deleteToken();
|
|
439
469
|
deleteKeyFile();
|
|
440
|
-
|
|
470
|
+
clearPairing();
|
|
441
471
|
return {
|
|
442
472
|
content: [{
|
|
443
473
|
type: "text",
|
|
@@ -492,6 +522,10 @@ Enter it in the Paigy app (Inbox \u2192 Add a new agent).`,
|
|
|
492
522
|
const { threadId } = GetThreadSchema.parse(request.params.arguments);
|
|
493
523
|
return { content: [{ type: "text", text: JSON.stringify(await getThread(threadId)) }] };
|
|
494
524
|
}
|
|
525
|
+
case "search_threads": {
|
|
526
|
+
const { q } = SearchThreadsSchema.parse(request.params.arguments);
|
|
527
|
+
return { content: [{ type: "text", text: JSON.stringify(await searchThreads(q)) }] };
|
|
528
|
+
}
|
|
495
529
|
case "set_task_state": {
|
|
496
530
|
const { notificationId, state } = SetTaskStateToolSchema.parse(request.params.arguments);
|
|
497
531
|
const result = await setTaskState(notificationId, state);
|
package/dist/listen.js
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
import {
|
|
3
3
|
WAKE_EVENT,
|
|
4
4
|
wakeChannel
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-LUY5MXDM.js";
|
|
6
6
|
import {
|
|
7
7
|
checkReplies,
|
|
8
8
|
registerDelivery
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-2374V6WK.js";
|
|
10
10
|
|
|
11
11
|
// src/listen.ts
|
|
12
12
|
import { createClient } from "@supabase/supabase-js";
|
package/dist/onboard.js
CHANGED
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
autoConfigureClients,
|
|
4
4
|
claudeInstallHint,
|
|
5
5
|
openBrowser
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-FEAIZ6DA.js";
|
|
7
7
|
import {
|
|
8
8
|
AGENT_NAME,
|
|
9
9
|
TOKEN_PATH,
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
requestCode,
|
|
12
12
|
saveToken,
|
|
13
13
|
sleep
|
|
14
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-2374V6WK.js";
|
|
15
15
|
|
|
16
16
|
// src/onboard.ts
|
|
17
17
|
async function main() {
|
package/dist/statusline.js
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paigy/mcp",
|
|
3
|
-
"version": "0.25.
|
|
4
|
-
"description": "Paigy MCP server
|
|
3
|
+
"version": "0.25.2",
|
|
4
|
+
"description": "Paigy MCP server — a voice inbox for your AI agents. Lets an agent notify a user and await their reply.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -22,13 +22,6 @@
|
|
|
22
22
|
"url": "git+https://github.com/mauurda/paigy.git",
|
|
23
23
|
"directory": "apps/mcp"
|
|
24
24
|
},
|
|
25
|
-
"scripts": {
|
|
26
|
-
"build": "tsup",
|
|
27
|
-
"dev": "tsup --watch",
|
|
28
|
-
"typecheck": "tsc --noEmit",
|
|
29
|
-
"test": "vitest run",
|
|
30
|
-
"prepublishOnly": "pnpm --filter @paigy/schema build && pnpm --filter @paigy/crypto build && pnpm --filter @paigy/sdk build && pnpm build"
|
|
31
|
-
},
|
|
32
25
|
"dependencies": {
|
|
33
26
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
34
27
|
"@supabase/supabase-js": "^2.47.10",
|
|
@@ -38,13 +31,19 @@
|
|
|
38
31
|
"zod-to-json-schema": "^3.24.1"
|
|
39
32
|
},
|
|
40
33
|
"devDependencies": {
|
|
41
|
-
"@paigy/crypto": "workspace:*",
|
|
42
|
-
"@paigy/schema": "workspace:*",
|
|
43
|
-
"@paigy/sdk": "workspace:*",
|
|
44
34
|
"@types/node": "^22.0.0",
|
|
45
35
|
"@types/qrcode-generator": "^1.0.6",
|
|
46
36
|
"tsup": "^8.3.5",
|
|
47
37
|
"typescript": "^5.7.2",
|
|
48
|
-
"vitest": "^2.1.8"
|
|
38
|
+
"vitest": "^2.1.8",
|
|
39
|
+
"@paigy/crypto": "0.0.0",
|
|
40
|
+
"@paigy/schema": "0.0.0",
|
|
41
|
+
"@paigy/sdk": "0.1.0"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsup",
|
|
45
|
+
"dev": "tsup --watch",
|
|
46
|
+
"typecheck": "tsc --noEmit",
|
|
47
|
+
"test": "vitest run"
|
|
49
48
|
}
|
|
50
|
-
}
|
|
49
|
+
}
|