@paigy/mcp 0.12.2 → 0.14.0
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/dist/{chunk-DUTOEZEL.js → chunk-OSQ3UXY6.js} +77 -5
- package/dist/{chunk-BJ2J2AOB.js → chunk-WGOGS2JC.js} +175 -13
- package/dist/index.js +91 -10
- package/dist/listen.js +3 -2
- package/dist/onboard.js +2 -1
- package/dist/statusline.js +7 -2
- package/package.json +3 -3
|
@@ -1,17 +1,77 @@
|
|
|
1
|
+
import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
|
|
1
2
|
import {
|
|
2
3
|
BACKEND_URL,
|
|
4
|
+
deriveAsk,
|
|
3
5
|
fetchRoster,
|
|
4
6
|
openSealedAnswer,
|
|
5
7
|
reach,
|
|
6
8
|
readKeyFile,
|
|
7
9
|
sealFields,
|
|
8
10
|
verifiedRecipients
|
|
9
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-WGOGS2JC.js";
|
|
10
12
|
|
|
11
13
|
// src/client.ts
|
|
12
14
|
import { existsSync, readFileSync } from "fs";
|
|
13
15
|
import { homedir } from "os";
|
|
14
16
|
import { join } from "path";
|
|
17
|
+
|
|
18
|
+
// src/when.ts
|
|
19
|
+
var WORD_NUMS = {
|
|
20
|
+
a: 1,
|
|
21
|
+
an: 1,
|
|
22
|
+
one: 1,
|
|
23
|
+
two: 2,
|
|
24
|
+
three: 3,
|
|
25
|
+
four: 4,
|
|
26
|
+
five: 5,
|
|
27
|
+
six: 6,
|
|
28
|
+
seven: 7,
|
|
29
|
+
eight: 8,
|
|
30
|
+
nine: 9,
|
|
31
|
+
ten: 10,
|
|
32
|
+
fifteen: 15,
|
|
33
|
+
twenty: 20,
|
|
34
|
+
thirty: 30,
|
|
35
|
+
forty: 40,
|
|
36
|
+
"forty-five": 45,
|
|
37
|
+
fifty: 50,
|
|
38
|
+
sixty: 60,
|
|
39
|
+
"half an": 0.5,
|
|
40
|
+
"a couple of": 2,
|
|
41
|
+
"a few": 3
|
|
42
|
+
};
|
|
43
|
+
function untilHour(hour, now) {
|
|
44
|
+
const due = new Date(now);
|
|
45
|
+
due.setHours(hour, 0, 0, 0);
|
|
46
|
+
if (due.getTime() <= now.getTime()) due.setDate(due.getDate() + 1);
|
|
47
|
+
return Math.round((due.getTime() - now.getTime()) / 1e3);
|
|
48
|
+
}
|
|
49
|
+
function parseDue(detail, now = /* @__PURE__ */ new Date()) {
|
|
50
|
+
const t = detail.toLowerCase().trim();
|
|
51
|
+
const rel = /(?:^|\b)in\s+(half an|a couple of|a few|an?|[\w-]+|\d+)\s*(minutes?|mins?|hours?|hrs?)\b/.exec(t);
|
|
52
|
+
if (rel) {
|
|
53
|
+
const n = /^\d+$/.test(rel[1]) ? Number(rel[1]) : WORD_NUMS[rel[1]];
|
|
54
|
+
if (n !== void 0) {
|
|
55
|
+
const unit = rel[2].startsWith("h") ? 3600 : 60;
|
|
56
|
+
return Math.round(n * unit);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (/after lunch|lunchtime/.test(t)) return untilHour(13, now);
|
|
60
|
+
if (/tomorrow/.test(t)) {
|
|
61
|
+
const due = new Date(now);
|
|
62
|
+
due.setDate(due.getDate() + 1);
|
|
63
|
+
due.setHours(9, 0, 0, 0);
|
|
64
|
+
return Math.round((due.getTime() - now.getTime()) / 1e3);
|
|
65
|
+
}
|
|
66
|
+
if (/tonight|this evening/.test(t)) {
|
|
67
|
+
return now.getHours() < 20 ? untilHour(20, now) : 3600;
|
|
68
|
+
}
|
|
69
|
+
if (/end of (the )?day|eod/.test(t)) return untilHour(17, now);
|
|
70
|
+
if (/later|in a (bit|while)/.test(t)) return 1800;
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/client.ts
|
|
15
75
|
var TOKEN_PATH = join(homedir(), ".paigy", "token.json");
|
|
16
76
|
function loadToken() {
|
|
17
77
|
if (process.env.PAIGY_TOKEN) return process.env.PAIGY_TOKEN;
|
|
@@ -33,6 +93,7 @@ function ensureAuthed(res) {
|
|
|
33
93
|
async function sealForE2ee(req, token, deps = {}) {
|
|
34
94
|
const keyFile = (deps.readKeyFile ?? readKeyFile)();
|
|
35
95
|
if (!keyFile?.e2ee) return req;
|
|
96
|
+
req = deriveAsk(req);
|
|
36
97
|
if (!keyFile.uikPub) {
|
|
37
98
|
throw new Error("E2EE pairing is missing its pinned identity key (key.json.uikPub) \u2014 re-pair; refusing to send plaintext.");
|
|
38
99
|
}
|
|
@@ -92,11 +153,21 @@ async function awaitReply(notificationId, opts = {}) {
|
|
|
92
153
|
}
|
|
93
154
|
}
|
|
94
155
|
function decryptItem(item) {
|
|
95
|
-
if (item.type !== "reply"
|
|
96
|
-
const
|
|
97
|
-
|
|
156
|
+
if (item.type !== "reply") return item;
|
|
157
|
+
const enriched = { ...item, intents: landIntents(item.intents) };
|
|
158
|
+
if (!enriched.sealed) return enriched;
|
|
159
|
+
const answer = decryptAnswer(enriched);
|
|
160
|
+
const { sealed: _s, ...rest } = enriched;
|
|
98
161
|
return { ...rest, answer };
|
|
99
162
|
}
|
|
163
|
+
function landIntents(intents) {
|
|
164
|
+
if (!intents?.length) return intents;
|
|
165
|
+
return intents.map((i) => {
|
|
166
|
+
if (i.kind !== "defer") return i;
|
|
167
|
+
const due = parseDue(i.detail);
|
|
168
|
+
return due !== null ? { ...i, dueInSeconds: due } : i;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
100
171
|
async function checkReplies() {
|
|
101
172
|
const token = loadToken();
|
|
102
173
|
const res = ensureAuthed(await reach(`${BACKEND_URL}/api/pending`, {
|
|
@@ -106,7 +177,8 @@ async function checkReplies() {
|
|
|
106
177
|
const pending = await res.json();
|
|
107
178
|
return {
|
|
108
179
|
...pending,
|
|
109
|
-
replies: pending.replies.map((
|
|
180
|
+
replies: pending.replies.map((raw) => {
|
|
181
|
+
const r = { ...raw, intents: landIntents(raw.intents) };
|
|
110
182
|
if (!r.sealed) return r;
|
|
111
183
|
const { sealed: _s, ...rest } = r;
|
|
112
184
|
return { ...rest, answer: decryptAnswer(r) };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
|
|
1
2
|
var __create = Object.create;
|
|
2
3
|
var __defProp = Object.defineProperty;
|
|
3
4
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
@@ -2286,6 +2287,9 @@ var NotifyRequestSchema = z.object({
|
|
|
2286
2287
|
options: z.array(OptionSchema.omit({ id: true })).min(1).optional().describe(
|
|
2287
2288
|
"The choices, in order \u2014 required when select is 'one'/'many'/'rank', omitted otherwise. Ids are assigned automatically by position ('1', '2', \u2026); the user's answer references them as optionId(s)."
|
|
2288
2289
|
),
|
|
2290
|
+
points: z.array(z.string().min(1)).optional().describe(
|
|
2291
|
+
"The distinct things you need answered, each a short phrase \u2014 on a call the broker keeps the conversation going until each is addressed, and the reply reports which were covered, so a half-answer is never silently returned as final. Omit for single-part asks."
|
|
2292
|
+
),
|
|
2289
2293
|
visuals: z.array(VisualSchema).optional().describe(
|
|
2290
2294
|
"Images attached to the message itself \u2014 context for the whole question (a screenshot, a chart). For a preview on one selectable choice, use that option's `html`/`image` instead."
|
|
2291
2295
|
),
|
|
@@ -2317,8 +2321,22 @@ var NotifyRequestSchema = z.object({
|
|
|
2317
2321
|
options: z.lazy(() => EnvelopeSchema).optional(),
|
|
2318
2322
|
visuals: z.lazy(() => EnvelopeSchema).optional()
|
|
2319
2323
|
}).optional(),
|
|
2320
|
-
select: z.enum(["one", "many", "rank", "confirm", "text"]).describe(
|
|
2321
|
-
"How the user answers \u2014 required, pick the shape that fits the question: 'one' = pick one option, 'many' = pick several, 'rank' = pick & order (each needs `options`); 'confirm' = yes/no or approve/deny; 'text' = free-form reply only (status updates, open questions). 'confirm' and 'text' take no options."
|
|
2324
|
+
select: z.enum(["one", "many", "rank", "confirm", "text"]).optional().describe(
|
|
2325
|
+
"How the user answers \u2014 required on the fully-shaped form, pick the shape that fits the question: 'one' = pick one option, 'many' = pick several, 'rank' = pick & order (each needs `options`); 'confirm' = yes/no or approve/deny; 'text' = free-form reply only (status updates, open questions). 'confirm' and 'text' take no options. Omit only when sending the simplified `ask` form \u2014 the broker picks the shape."
|
|
2326
|
+
),
|
|
2327
|
+
/** The simplified form (#395): instead of shaping the notification yourself
|
|
2328
|
+
* (context/select/options/urgency), state what you need to learn and why it
|
|
2329
|
+
* matters now — the broker derives the optimal shape and channel. Mutually
|
|
2330
|
+
* exclusive with `context` (and never sent alongside `envelope`: E2EE pairings
|
|
2331
|
+
* derive agent-side before sealing, so the server only ever shapes plaintext). */
|
|
2332
|
+
ask: z.string().min(1).optional().describe(
|
|
2333
|
+
'SIMPLIFIED FORM \u2014 state in plain prose what you need to learn from the user and why it matters now (e.g. "I need to know whether to deploy the auth fix \u2014 tests are green, staging verified"). Paigy derives the title, answer shape, options, and delivery channel for you. Mutually exclusive with context/select/options \u2014 send one form or the other.'
|
|
2334
|
+
),
|
|
2335
|
+
needs: z.array(z.string().min(1)).optional().describe(
|
|
2336
|
+
"With `ask` only: the distinct things you need answered when the ask is multi-part \u2014 becomes the coverage contract (`points`), so a half-answer is never silently final."
|
|
2337
|
+
),
|
|
2338
|
+
urgencyHint: z.enum(["whenever", "soon", "now"]).optional().describe(
|
|
2339
|
+
"With `ask` only: how urgently you need the answer \u2014 'whenever' (inbox), 'soon' (worth a heads-up), 'now' (you're blocked this minute). A hint, not a command: the user's settings still have the final word."
|
|
2322
2340
|
),
|
|
2323
2341
|
confirmStyle: z.enum(["yesno", "approve"]).default("yesno").describe(
|
|
2324
2342
|
"Labels for a select:'confirm' paige \u2014 'yesno' (Yes/No) or 'approve' (Approve/Deny). Ignored unless select is 'confirm'."
|
|
@@ -2335,16 +2353,48 @@ var NotifyRequestSchema = z.object({
|
|
|
2335
2353
|
if (r[f] !== void 0)
|
|
2336
2354
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [f], message: `E2EE request must not carry plaintext ${f} \u2014 it's sealed in envelope.${f}` });
|
|
2337
2355
|
}
|
|
2356
|
+
if (r.points !== void 0)
|
|
2357
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["points"], message: "E2EE request must not carry plaintext points" });
|
|
2358
|
+
if (r.ask !== void 0)
|
|
2359
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["ask"], message: "E2EE request must not carry a plaintext ask \u2014 derive the shape agent-side and seal it" });
|
|
2360
|
+
if (r.needs !== void 0)
|
|
2361
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["needs"], message: "E2EE request must not carry plaintext needs" });
|
|
2362
|
+
return;
|
|
2363
|
+
}
|
|
2364
|
+
if (r.ask !== void 0) {
|
|
2365
|
+
for (const f of ["context", "options", "select", "points", "visuals"]) {
|
|
2366
|
+
if (r[f] !== void 0)
|
|
2367
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [f], message: `the simplified \`ask\` form takes no ${f} \u2014 the broker derives it (use \`needs\` for multi-part asks)` });
|
|
2368
|
+
}
|
|
2338
2369
|
return;
|
|
2339
2370
|
}
|
|
2371
|
+
if (r.needs !== void 0 || r.urgencyHint !== void 0)
|
|
2372
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["needs"], message: "needs/urgencyHint belong to the simplified `ask` form \u2014 with a shaped request use points/urgency" });
|
|
2340
2373
|
if (!r.context)
|
|
2341
2374
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["context"], message: "context is required (plaintext path)" });
|
|
2375
|
+
if (!r.select)
|
|
2376
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["select"], message: "select is required on the shaped form" });
|
|
2342
2377
|
const needsOptions = r.select === "one" || r.select === "many" || r.select === "rank";
|
|
2343
2378
|
if (needsOptions && !r.options?.length)
|
|
2344
2379
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["options"], message: `select:'${r.select}' requires options` });
|
|
2345
2380
|
if (!needsOptions && r.options?.length)
|
|
2346
2381
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["options"], message: `select:'${r.select}' takes no options` });
|
|
2347
2382
|
});
|
|
2383
|
+
function deriveAsk(req) {
|
|
2384
|
+
if (!req.ask) return req;
|
|
2385
|
+
const text = req.ask.trim();
|
|
2386
|
+
const firstSentence = (/^[^.!?\n]+[.!?]?/.exec(text)?.[0] ?? text).trim();
|
|
2387
|
+
const title = firstSentence.length > 90 ? `${firstSentence.slice(0, 87).trimEnd()}\u2026` : firstSentence;
|
|
2388
|
+
const urgency = req.urgencyHint === "now" ? "call" : req.urgencyHint === "soon" ? "banner" : req.urgencyHint === "whenever" ? "inbox" : req.urgency;
|
|
2389
|
+
const { ask: _ask, needs, urgencyHint: _hint, ...rest } = req;
|
|
2390
|
+
return {
|
|
2391
|
+
...rest,
|
|
2392
|
+
context: { title, description: [text] },
|
|
2393
|
+
select: "text",
|
|
2394
|
+
urgency,
|
|
2395
|
+
...needs?.length ? { points: needs } : {}
|
|
2396
|
+
};
|
|
2397
|
+
}
|
|
2348
2398
|
var NotifyStatusSchema = z.enum(["pending", "answered", "ignored"]);
|
|
2349
2399
|
var AgentStateSchema = z.enum(["idle", "in_progress", "completed", "needs_input"]);
|
|
2350
2400
|
var SetTaskStateSchema = z.object({
|
|
@@ -2364,6 +2414,15 @@ var UserAnswerSchema = z.discriminatedUnion("kind", [
|
|
|
2364
2414
|
z.object({ kind: z.literal("confirm"), approved: z.boolean() }),
|
|
2365
2415
|
z.object({ kind: z.literal("turns"), turns: z.array(TurnSchema).min(1) })
|
|
2366
2416
|
]);
|
|
2417
|
+
var IntentSchema = z.object({
|
|
2418
|
+
kind: z.enum(["defer", "delegate", "channel"]),
|
|
2419
|
+
detail: z.string(),
|
|
2420
|
+
/** Landed defer (#397): the MCP parses common spoken forms ("in 20 minutes",
|
|
2421
|
+
* "after lunch") against the agent machine's clock — the user's — and attaches
|
|
2422
|
+
* the seconds, ready to pass straight to schedule_callback. Absent when the
|
|
2423
|
+
* detail didn't parse (the agent interprets it) or the kind isn't defer. */
|
|
2424
|
+
dueInSeconds: z.number().int().positive().optional()
|
|
2425
|
+
});
|
|
2367
2426
|
var AwaitItemSchema = z.discriminatedUnion("type", [
|
|
2368
2427
|
z.object({
|
|
2369
2428
|
type: z.literal("reply"),
|
|
@@ -2376,7 +2435,15 @@ var AwaitItemSchema = z.discriminatedUnion("type", [
|
|
|
2376
2435
|
* against `ignored` as tampering. Absent = today's plaintext answer (in `answer`).
|
|
2377
2436
|
* On a sealed reply the plaintext `answer` is a placeholder (kind reflects only
|
|
2378
2437
|
* the `ignored` bit) — never the real content, which stays sealed. */
|
|
2379
|
-
sealed: z.lazy(() => SealedAnswerSchema).optional()
|
|
2438
|
+
sealed: z.lazy(() => SealedAnswerSchema).optional(),
|
|
2439
|
+
/** Broker extras (#381), present when the answer was mapped from a call: next
|
|
2440
|
+
* steps the user attached ("call me after lunch" → defer — act on it via
|
|
2441
|
+
* schedule_callback) and the raw words the shaped answer was mapped from. */
|
|
2442
|
+
intents: z.array(IntentSchema).optional(),
|
|
2443
|
+
transcript: z.string().optional(),
|
|
2444
|
+
/** Coverage report (#396), when the ask declared `points`: which of them this
|
|
2445
|
+
* answer addressed. Missing points = re-ask or proceed knowingly partial. */
|
|
2446
|
+
covered: z.array(z.string()).optional()
|
|
2380
2447
|
}),
|
|
2381
2448
|
z.object({
|
|
2382
2449
|
type: z.literal("remind"),
|
|
@@ -2404,7 +2471,14 @@ var PendingRepliesSchema = z.object({
|
|
|
2404
2471
|
/** E2EE: the sealed answer (opaque envelope + plaintext `ignored` hint) when the
|
|
2405
2472
|
* pairing is E2EE — the agent opens it and re-derives the real answer. Absent =
|
|
2406
2473
|
* plaintext answer (in `answer`). See AwaitItemSchema's reply variant. */
|
|
2407
|
-
sealed: z.lazy(() => SealedAnswerSchema).optional()
|
|
2474
|
+
sealed: z.lazy(() => SealedAnswerSchema).optional(),
|
|
2475
|
+
/** Broker extras (#381): next steps the user attached to a call-mapped answer
|
|
2476
|
+
* ("call me after lunch" → defer — act on it via schedule_callback) and the
|
|
2477
|
+
* raw words the shaped answer was mapped from. */
|
|
2478
|
+
intents: z.array(IntentSchema).optional(),
|
|
2479
|
+
transcript: z.string().optional(),
|
|
2480
|
+
/** Coverage report (#396): which declared `points` this answer addressed. */
|
|
2481
|
+
covered: z.array(z.string()).optional()
|
|
2408
2482
|
})
|
|
2409
2483
|
),
|
|
2410
2484
|
pending: z.array(
|
|
@@ -2436,22 +2510,30 @@ var NotifyResponseSchema = z.object({
|
|
|
2436
2510
|
answer: UserAnswerSchema.optional(),
|
|
2437
2511
|
answeredAt: z.string().datetime().optional()
|
|
2438
2512
|
});
|
|
2439
|
-
var IntentSchema = z.object({
|
|
2440
|
-
kind: z.enum(["defer", "delegate", "channel"]),
|
|
2441
|
-
detail: z.string()
|
|
2442
|
-
});
|
|
2443
2513
|
var UserResponseSchema = z.object({
|
|
2444
2514
|
requestId: z.string(),
|
|
2445
2515
|
answer: UserAnswerSchema,
|
|
2446
2516
|
answeredAt: z.string().datetime(),
|
|
2447
2517
|
intents: z.array(IntentSchema).optional(),
|
|
2448
|
-
transcript: z.string().optional()
|
|
2518
|
+
transcript: z.string().optional(),
|
|
2519
|
+
/** E2EE: the sealed answer (opaque envelope + plaintext `ignored` hint) when the item was
|
|
2520
|
+
* E2EE-sealed. Present → the server persists it opaquely and branches status on `ignored`;
|
|
2521
|
+
* the plaintext `answer` is a placeholder (`{ kind: "ignored" }`) the server ignores for a
|
|
2522
|
+
* sealed row. Absent = today's plaintext answer, unchanged. */
|
|
2523
|
+
sealed: z.lazy(() => SealedAnswerSchema).optional(),
|
|
2524
|
+
/** Coverage report (#396): which of the ask's declared `points` were addressed. */
|
|
2525
|
+
covered: z.array(z.string()).optional()
|
|
2449
2526
|
});
|
|
2450
2527
|
var InboxItemSchema = z.object({
|
|
2451
2528
|
id: z.string(),
|
|
2452
2529
|
status: NotifyStatusSchema,
|
|
2453
2530
|
context: ContextSchema,
|
|
2454
2531
|
options: z.array(OptionSchema).optional(),
|
|
2532
|
+
/** The ask's declared coverage points (#396), when the agent sent them. */
|
|
2533
|
+
points: z.array(z.string()).optional(),
|
|
2534
|
+
/** On a replied detail (#397): the next steps the user attached to the answer
|
|
2535
|
+
* ("call back after lunch") — shown so they can see the commitment was captured. */
|
|
2536
|
+
intents: z.array(IntentSchema).optional(),
|
|
2455
2537
|
visuals: z.array(VisualSchema).optional(),
|
|
2456
2538
|
agent: z.string(),
|
|
2457
2539
|
nickname: z.string(),
|
|
@@ -2478,7 +2560,22 @@ var InboxItemSchema = z.object({
|
|
|
2478
2560
|
* inbox's blocking badge and the extra confirm step before dismissing it. */
|
|
2479
2561
|
blocking: z.boolean().default(false),
|
|
2480
2562
|
/** The user's locked-in answer; present only for replied items (GET /api/replied/:id). */
|
|
2481
|
-
answer: UserAnswerSchema.optional()
|
|
2563
|
+
answer: UserAnswerSchema.optional(),
|
|
2564
|
+
/** E2EE (text lane): the sealed content this item carries when the pairing is E2EE, in
|
|
2565
|
+
* place of the plaintext `context`/`options`/`visuals` (which the operator would
|
|
2566
|
+
* otherwise read). A per-field map of opaque Envelopes — the SAME shape as
|
|
2567
|
+
* NotifyRequest.envelope. The native app decrypts it locally via openNotification;
|
|
2568
|
+
* web / an un-enrolled device can't and renders a locked placeholder. Absent = today's
|
|
2569
|
+
* plaintext item (context carries the cleartext), so plaintext items are unchanged. */
|
|
2570
|
+
envelope: z.object({
|
|
2571
|
+
context: z.lazy(() => EnvelopeSchema).optional(),
|
|
2572
|
+
options: z.lazy(() => EnvelopeSchema).optional(),
|
|
2573
|
+
visuals: z.lazy(() => EnvelopeSchema).optional()
|
|
2574
|
+
}).optional(),
|
|
2575
|
+
/** E2EE: the agent's device X25519 public key to seal the user's answer BACK to (the
|
|
2576
|
+
* sender the phone replies to). Sourced server-side from this item's pairing credential.
|
|
2577
|
+
* Present only alongside `envelope`; the phone seals via sealAnswer(answer, this, id). */
|
|
2578
|
+
agentX25519: z.string().optional()
|
|
2482
2579
|
});
|
|
2483
2580
|
var SnoozeRequestSchema = z.object({
|
|
2484
2581
|
requestId: z.string(),
|
|
@@ -2500,6 +2597,15 @@ var MissedCallSchema = z.enum([
|
|
|
2500
2597
|
"inbox",
|
|
2501
2598
|
"dismiss"
|
|
2502
2599
|
]);
|
|
2600
|
+
var BrokerTuningSchema = z.object({
|
|
2601
|
+
/** 'none' = skip the spoken ack after a mapped answer (power users find it slow). */
|
|
2602
|
+
ackVerbosity: z.enum(["normal", "none"]).optional(),
|
|
2603
|
+
/** How readily the mapper asks its one clarification: 'low' = only when truly
|
|
2604
|
+
* uninterpretable, 'high' = whenever not fully certain. */
|
|
2605
|
+
clarifyEagerness: z.enum(["low", "normal", "high"]).optional(),
|
|
2606
|
+
/** The user's own shorthand: when they say `say`, they mean `mean`. */
|
|
2607
|
+
phrasebook: z.array(z.object({ say: z.string().min(1).max(60), mean: z.string().min(1).max(120) })).max(24).optional()
|
|
2608
|
+
});
|
|
2503
2609
|
var UserSettingsSchema = z.object({
|
|
2504
2610
|
permissions: z.object({
|
|
2505
2611
|
call: z.boolean(),
|
|
@@ -2525,7 +2631,10 @@ var UserSettingsSchema = z.object({
|
|
|
2525
2631
|
* settings object without it can't silently flip the account's E2EE state.
|
|
2526
2632
|
* Absent = leave unchanged on write, 'off' on read (see store.ts). The demo
|
|
2527
2633
|
* account is plaintext by construction and refuses any non-'off' value. */
|
|
2528
|
-
e2eeMode: z.enum(["off", "on"]).optional()
|
|
2634
|
+
e2eeMode: z.enum(["off", "on"]).optional(),
|
|
2635
|
+
/** Rung-2 broker tuning (#381). Optional and NOT defaulted, same stale-client
|
|
2636
|
+
* clobber guard as voiceMode: absent = leave unchanged on write. */
|
|
2637
|
+
broker: BrokerTuningSchema.optional()
|
|
2529
2638
|
});
|
|
2530
2639
|
var HistoryItemSchema = z.object({
|
|
2531
2640
|
id: z.string(),
|
|
@@ -2663,8 +2772,15 @@ var DeviceInfoSchema = z.object({
|
|
|
2663
2772
|
proto: z.string().nullable().optional(),
|
|
2664
2773
|
agent_commitment: z.string().nullable().optional(),
|
|
2665
2774
|
// Ca
|
|
2666
|
-
agent_reveal: PairingRevealSchema.nullable().optional()
|
|
2775
|
+
agent_reveal: PairingRevealSchema.nullable().optional(),
|
|
2667
2776
|
// present once the agent reveals
|
|
2777
|
+
agent_credential: DeviceCredentialSchema.nullable().optional(),
|
|
2778
|
+
// the phone's UIK-signed cred over the agent's keys — the agent fetches it here to finalizeE2ee
|
|
2779
|
+
// The phone's reveal + identity key, exposed here so a GATED agent (e2ee_mode='on', token
|
|
2780
|
+
// withheld until e2ee=true) can compute the SAS WITHOUT a token. Public keys — same safety
|
|
2781
|
+
// class as agent_reveal above. Present only once the phone reveals; null otherwise.
|
|
2782
|
+
phone_reveal: PairingRevealSchema.nullable().optional(),
|
|
2783
|
+
uik_pub: z.string().nullable().optional()
|
|
2668
2784
|
});
|
|
2669
2785
|
var DeviceTokenRequestSchema = z.object({
|
|
2670
2786
|
device_code: z.string(),
|
|
@@ -2987,6 +3103,32 @@ var PROTO = "paigy-pair-v2|sas=24";
|
|
|
2987
3103
|
var SAS_BITS = 24;
|
|
2988
3104
|
var TOKEN_PATH = join(homedir(), ".paigy", "token.json");
|
|
2989
3105
|
var KEY_PATH = join(homedir(), ".paigy", "key.json");
|
|
3106
|
+
var SURFACE_PATH = join(homedir(), ".paigy", "surface.json");
|
|
3107
|
+
function writeSurface(label, code, ttlSeconds) {
|
|
3108
|
+
try {
|
|
3109
|
+
mkdirSync(join(homedir(), ".paigy"), { recursive: true });
|
|
3110
|
+
writeFileSync(
|
|
3111
|
+
SURFACE_PATH,
|
|
3112
|
+
JSON.stringify({ label, code, expiresAt: Date.now() + ttlSeconds * 1e3 }),
|
|
3113
|
+
{ mode: 384 }
|
|
3114
|
+
);
|
|
3115
|
+
} catch {
|
|
3116
|
+
}
|
|
3117
|
+
}
|
|
3118
|
+
function clearSurface() {
|
|
3119
|
+
try {
|
|
3120
|
+
rmSync(SURFACE_PATH, { force: true });
|
|
3121
|
+
} catch {
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
function readSurface(now = Date.now) {
|
|
3125
|
+
try {
|
|
3126
|
+
const { label, code, expiresAt } = JSON.parse(readFileSync(SURFACE_PATH, "utf8"));
|
|
3127
|
+
return label && code && typeof expiresAt === "number" && expiresAt > now() ? { label, code } : null;
|
|
3128
|
+
} catch {
|
|
3129
|
+
return null;
|
|
3130
|
+
}
|
|
3131
|
+
}
|
|
2990
3132
|
function openBrowser(url) {
|
|
2991
3133
|
const cmd = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open";
|
|
2992
3134
|
const args = platform() === "win32" ? ["/c", "start", url] : [url];
|
|
@@ -3099,6 +3241,13 @@ async function fetchCredential(userCode) {
|
|
|
3099
3241
|
const body = await res.json().catch(() => ({}));
|
|
3100
3242
|
return body.agent_credential ?? null;
|
|
3101
3243
|
}
|
|
3244
|
+
async function fetchPhoneReveal(userCode) {
|
|
3245
|
+
const res = await reach(`${BACKEND_URL}/api/device/info?code=${encodeURIComponent(userCode)}`);
|
|
3246
|
+
if (!res.ok) return null;
|
|
3247
|
+
const body = await res.json().catch(() => ({}));
|
|
3248
|
+
if (!body.phone_reveal || !body.uik_pub) return null;
|
|
3249
|
+
return { phone_reveal: body.phone_reveal, uik_pub: body.uik_pub };
|
|
3250
|
+
}
|
|
3102
3251
|
async function pairStep(deviceCode, keyFile) {
|
|
3103
3252
|
if (!keyFile) {
|
|
3104
3253
|
const token2 = await pollToken(deviceCode);
|
|
@@ -3110,7 +3259,16 @@ async function pairStep(deviceCode, keyFile) {
|
|
|
3110
3259
|
return token2 ? { kind: "paired", token: token2 } : { kind: "pending" };
|
|
3111
3260
|
}
|
|
3112
3261
|
const token = await pollToken(deviceCode, agentReveal(keyFile));
|
|
3113
|
-
if (!token)
|
|
3262
|
+
if (!token) {
|
|
3263
|
+
const gated = keyFile.userCode ? await fetchPhoneReveal(keyFile.userCode) : null;
|
|
3264
|
+
if (!gated) return { kind: "pending" };
|
|
3265
|
+
try {
|
|
3266
|
+
const sas = computeSAS(keyFile, cb, gated.phone_reveal, gated.uik_pub);
|
|
3267
|
+
return { kind: "awaiting_confirm", sas, uikPub: gated.uik_pub };
|
|
3268
|
+
} catch (e) {
|
|
3269
|
+
return { kind: "e2ee_aborted", warning: e.message };
|
|
3270
|
+
}
|
|
3271
|
+
}
|
|
3114
3272
|
if (!token.phone_reveal || !token.uik_pub) return { kind: "paired", token };
|
|
3115
3273
|
try {
|
|
3116
3274
|
const sas = computeSAS(keyFile, cb, token.phone_reveal, token.uik_pub);
|
|
@@ -3161,6 +3319,7 @@ function openSealedAnswer(sealed, keypair) {
|
|
|
3161
3319
|
|
|
3162
3320
|
export {
|
|
3163
3321
|
NotifyRequestSchema,
|
|
3322
|
+
deriveAsk,
|
|
3164
3323
|
SetTaskStateSchema,
|
|
3165
3324
|
ScheduleCallbackSchema,
|
|
3166
3325
|
WAKE_EVENT,
|
|
@@ -3169,6 +3328,9 @@ export {
|
|
|
3169
3328
|
reach,
|
|
3170
3329
|
AGENT_NAME,
|
|
3171
3330
|
TOKEN_PATH,
|
|
3331
|
+
writeSurface,
|
|
3332
|
+
clearSurface,
|
|
3333
|
+
readSurface,
|
|
3172
3334
|
openBrowser,
|
|
3173
3335
|
saveToken,
|
|
3174
3336
|
sleep,
|
package/dist/index.js
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
|
|
2
3
|
import {
|
|
3
4
|
awaitReply,
|
|
4
5
|
checkReplies,
|
|
5
6
|
scheduleCallback,
|
|
6
7
|
setTaskState,
|
|
7
8
|
submitNotification
|
|
8
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-OSQ3UXY6.js";
|
|
9
10
|
import {
|
|
10
11
|
NotifyRequestSchema,
|
|
11
12
|
ScheduleCallbackSchema,
|
|
12
13
|
SetTaskStateSchema,
|
|
14
|
+
clearSurface,
|
|
13
15
|
deleteKeyFile,
|
|
14
16
|
deleteToken,
|
|
15
17
|
fetchCredential,
|
|
16
18
|
finalizeE2ee,
|
|
17
|
-
openBrowser,
|
|
18
19
|
pairStep,
|
|
19
20
|
readKeyFile,
|
|
20
21
|
readToken,
|
|
@@ -23,8 +24,9 @@ import {
|
|
|
23
24
|
saveKeyFile,
|
|
24
25
|
saveToken,
|
|
25
26
|
sleep,
|
|
26
|
-
startE2ee
|
|
27
|
-
|
|
27
|
+
startE2ee,
|
|
28
|
+
writeSurface
|
|
29
|
+
} from "./chunk-WGOGS2JC.js";
|
|
28
30
|
|
|
29
31
|
// src/index.ts
|
|
30
32
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
@@ -34,6 +36,52 @@ import {
|
|
|
34
36
|
ListToolsRequestSchema
|
|
35
37
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
36
38
|
import { execSync } from "child_process";
|
|
39
|
+
|
|
40
|
+
// src/lint.ts
|
|
41
|
+
var TITLE_MAX = 90;
|
|
42
|
+
var CHUNKS_MAX = 8;
|
|
43
|
+
var CHUNK_MAX = 300;
|
|
44
|
+
var ASK_MAX = 600;
|
|
45
|
+
var OPTION_MAX = 80;
|
|
46
|
+
var NEEDS_MAX = 6;
|
|
47
|
+
var UNSPEAKABLE = /```|\n/;
|
|
48
|
+
function lintNotify(req) {
|
|
49
|
+
const problems = [];
|
|
50
|
+
if (req.ask !== void 0) {
|
|
51
|
+
if (req.ask.length > ASK_MAX)
|
|
52
|
+
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`);
|
|
53
|
+
if (UNSPEAKABLE.test(req.ask))
|
|
54
|
+
problems.push("ask contains code fences or newlines \u2014 write it as plain prose (it may be read aloud on a call)");
|
|
55
|
+
for (const n of req.needs ?? []) {
|
|
56
|
+
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)`);
|
|
57
|
+
}
|
|
58
|
+
if ((req.needs?.length ?? 0) > NEEDS_MAX)
|
|
59
|
+
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`);
|
|
60
|
+
return problems;
|
|
61
|
+
}
|
|
62
|
+
const spoken = req.urgency === "call" || req.urgency === "banner";
|
|
63
|
+
if (req.context) {
|
|
64
|
+
if (req.context.title.length > TITLE_MAX)
|
|
65
|
+
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)`);
|
|
66
|
+
if (UNSPEAKABLE.test(req.context.title))
|
|
67
|
+
problems.push("title contains code fences or newlines \u2014 one plain-prose line");
|
|
68
|
+
if (req.context.description.length > CHUNKS_MAX)
|
|
69
|
+
problems.push(`${req.context.description.length} description chunks \u2014 cap at ${CHUNKS_MAX}; merge or drop the rest`);
|
|
70
|
+
for (const [i, chunk] of req.context.description.entries()) {
|
|
71
|
+
if (chunk.length > CHUNK_MAX)
|
|
72
|
+
problems.push(`description[${i}] is ${chunk.length} chars \u2014 split it into standalone points of \u2264${CHUNK_MAX}`);
|
|
73
|
+
}
|
|
74
|
+
if (spoken && UNSPEAKABLE.test(req.context.description.join(" ")))
|
|
75
|
+
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)");
|
|
76
|
+
}
|
|
77
|
+
for (const o of req.options ?? []) {
|
|
78
|
+
if (o.label.length > OPTION_MAX)
|
|
79
|
+
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`);
|
|
80
|
+
}
|
|
81
|
+
return problems;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// src/index.ts
|
|
37
85
|
import { z } from "zod";
|
|
38
86
|
import qrcode from "qrcode-generator";
|
|
39
87
|
|
|
@@ -90,6 +138,7 @@ function detectGit() {
|
|
|
90
138
|
};
|
|
91
139
|
}
|
|
92
140
|
function pairedResult(token, sas, note, e2ee) {
|
|
141
|
+
clearSurface();
|
|
93
142
|
const base = {
|
|
94
143
|
status: "paired",
|
|
95
144
|
nickname: token.nickname,
|
|
@@ -104,6 +153,22 @@ function pairedResult(token, sas, note, e2ee) {
|
|
|
104
153
|
if (note) base.note = note;
|
|
105
154
|
return { content: [{ type: "text", text: JSON.stringify(base) }] };
|
|
106
155
|
}
|
|
156
|
+
function awaitingConfirmResult(sas, device_code) {
|
|
157
|
+
writeSurface("verify code", sas, 600);
|
|
158
|
+
return {
|
|
159
|
+
content: [{
|
|
160
|
+
type: "text",
|
|
161
|
+
text: JSON.stringify({
|
|
162
|
+
status: "awaiting_confirmation",
|
|
163
|
+
e2ee: true,
|
|
164
|
+
sas,
|
|
165
|
+
device_code,
|
|
166
|
+
verify_message: `YOUR NEXT MESSAGE TO THE USER MUST BE EXACTLY THIS CODE AND NOTHING ELSE, displayed large (a markdown heading): ${sas} \u2014 it is the verification code they must compare and type on their phone. Do not wrap it in explanation; every extra sentence buries it. This code REPLACES any earlier one (older pairing attempts are now stale).`,
|
|
167
|
+
message: "No token is issued until the user confirms the code match on their phone (E2EE account). After sending the bare code, call pair again with this device_code to keep waiting \u2014 the token arrives only after they confirm."
|
|
168
|
+
})
|
|
169
|
+
}]
|
|
170
|
+
};
|
|
171
|
+
}
|
|
107
172
|
var server = new Server(
|
|
108
173
|
{ name: "paigy", version: "0.0.0" },
|
|
109
174
|
{
|
|
@@ -125,17 +190,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
125
190
|
},
|
|
126
191
|
{
|
|
127
192
|
name: "notify_user",
|
|
128
|
-
description: "Notify the user via Paigy. Returns { notificationId, threadId } \u2014 pass notificationId to await_reply for the answer, threadId to notify_user to continue the conversation.
|
|
193
|
+
description: "Notify the user via Paigy. Returns { notificationId, threadId } \u2014 pass notificationId to await_reply for the answer, threadId to notify_user to continue the conversation. SIMPLEST FORM \u2014 just state what you need: { ask: \"I need to know whether to deploy the auth fix \u2014 tests are green, staging verified\", urgencyHint: 'now'|'soon'|'whenever', needs?: [\"deploy?\", \"keep the flag?\"] }. Paigy's broker derives the title, answer shape, options, and delivery channel for you \u2014 prefer this unless you specifically need to control the exact options/shape. The fully-shaped form below remains available and unchanged (the two are mutually exclusive: send `ask` OR `context`+`select`). FULLY-SHAPED FORM: provide context.title (a specific, non-empty one-line headline \u2014 this is what the user sees first, and what shows on the ring for a call) and context.description (an array of standalone, non-empty detail chunks the user can selectively ask you to expand). Set `urgency`: 'inbox' (default) drops it silently in their inbox; 'push' is a quiet passive notification (no sound); 'banner' sends a time-sensitive banner/lock-screen push (a 'paige') they tap to open \u2014 for when you need them soon-ish but not enough to ring them; 'call' rings their phone now as a voice call \u2014 only when you genuinely need them in the moment (blocked/waiting, time-sensitive). This is the premier use case for Paigy: getting UNBLOCKED so you can keep working, not just reporting that you're stuck. The user started a big task and went to do something else \u2014 the cardinal failure is going idle on one small decision and silently waiting to be checked on, so they come back to find you never actually progressed. Judge urgency by how much WORK IS BLOCKED behind this decision (a lot of dependent downstream work \u2192 call, even if it's a single question), not by how many things happen to be pending \u2014 one blocking decision outweighs five independent low-stakes ones sitting in the inbox. Set `blocking: true` whenever that's the case, independent of `urgency` \u2014 it's what lets Paigy escalate this to a real call later on its own if it goes unanswered, even if you sent it at a lower urgency. Don't set it for things you could work around, defer, or where other useful work exists meanwhile. ON A CALL, your title + description are READ ALOUD by a voice \u2014 write them to be HEARD, not read: keep it short and conversational, front-load the ask, and refer to things BY NAME, not by ID or code (say 'the pull request about the agents page', not 'PR #235'; 'the login-bug ticket', not 'ABC-1234'). Spell out only what's natural to say out loud. MATCH the answer shape to the question \u2014 `select` is required; pick the best tool for the job, not always yes/no. The user can ALWAYS add free text on top of any shape, so structuring loses nothing. Choose `select`: yes/no \u2192 select:'confirm' \u2192 {kind:'confirm', approved:boolean}. Approve/deny an action \u2192 select:'confirm' + confirmStyle:'approve' \u2192 {kind:'confirm', approved:boolean}. Both are answerable right from the banner \u2014 no need to open the app. Pick one of several \u2192 options + select:'one' \u2192 {kind:'option', optionId}. Pick several / a subset \u2192 options + select:'many' \u2192 {kind:'multi', optionIds:[...]}. Rank or prioritize \u2192 options + select:'rank', user taps in preferred order \u2192 {kind:'ranked', optionIds:[...]}. One/many/rank/text all need the user to open the app to answer \u2014 only confirm is answerable straight from the banner. Options carry no ids \u2014 they're assigned by position ('1', '2', \u2026), and the answer's optionId(s) are those positions. For visual choices give each option a sandboxed `html` or an `image` preview (e.g. layout/UI alternatives); use `visuals` for images that set context for the whole question. select:'text' = free-form reply only (plain updates, or answers that genuinely can't be structured). If a reply comes back as {kind:'clarify', chunks:[...]}, the user wants more detail on those chunks \u2014 respond via notify_user with the SAME threadId and an expanded description. Pass `threadId` from a prior notify_user result or an await_reply reply to continue that conversation thread; omit it to start a new one. To follow up on a call (e.g. the user asked you to 'call me back when it's done'), reuse the threadId from that call's reply so it threads as the same conversation.",
|
|
129
194
|
inputSchema: json(NotifyRequestSchema)
|
|
130
195
|
},
|
|
131
196
|
{
|
|
132
197
|
name: "await_reply",
|
|
133
|
-
description: "Wait for the user's reply to a specific notification you sent (pass the notificationId from notify_user). 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 notify_user 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 (notify_user with the reply's threadId) when the task is done or you hit a blocker \u2014 urgency:'call' for a blocker, 'banner'/'push'/'inbox' for done. Paigy has no scheduler; the callback is yours to send (use ScheduleWakeup/cron for timing).",
|
|
198
|
+
description: "Wait for the user's reply to a specific notification you sent (pass the notificationId from notify_user). 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 notify_user 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 (notify_user with the reply's threadId) when the task is done or you hit a blocker \u2014 urgency:'call' for a blocker, 'banner'/'push'/'inbox' 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 (lower urgency). `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 (notify_user on the same threadId) or proceed knowingly partial; never treat a partial answer as complete.",
|
|
134
199
|
inputSchema: json(AwaitReplySchema)
|
|
135
200
|
},
|
|
136
201
|
{
|
|
137
202
|
name: "check_replies",
|
|
138
|
-
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 notify_user call you just made, use await_reply instead. Also returns owedCallbacks: callbacks now due that you promised \u2014 fulfill each with notify_user 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.",
|
|
203
|
+
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 notify_user call you just made, use await_reply instead. Also returns owedCallbacks: callbacks now due that you promised \u2014 fulfill each with notify_user 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.",
|
|
139
204
|
inputSchema: json(z.object({}))
|
|
140
205
|
},
|
|
141
206
|
{
|
|
@@ -158,7 +223,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
158
223
|
const { keyFile, offer } = startE2ee();
|
|
159
224
|
const code = await requestCode(void 0, offer);
|
|
160
225
|
saveKeyFile({ ...keyFile, userCode: code.user_code });
|
|
161
|
-
|
|
226
|
+
writeSurface("pairing code", code.user_code, code.expires_in);
|
|
162
227
|
const qr = qrcode(0, "M");
|
|
163
228
|
qr.addData(code.verification_uri_complete);
|
|
164
229
|
qr.make();
|
|
@@ -172,7 +237,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
172
237
|
device_code: code.device_code,
|
|
173
238
|
expires_in: code.expires_in,
|
|
174
239
|
qr: qr.createASCII(1, 2),
|
|
175
|
-
message:
|
|
240
|
+
message: `YOUR NEXT MESSAGE TO THE USER MUST BE EXACTLY THIS CODE displayed large (a markdown heading): ${code.user_code} \u2014 plus ONE short line: enter it in the Paigy app (Inbox \u2192 Add a new agent). No other prose. Do NOT open a browser for the user. If their phone is handy they may prefer scanning \`qr\` (print it verbatim in a fenced code block on request). Then immediately call pair again with this device_code \u2014 it polls for the approval.`
|
|
176
241
|
})
|
|
177
242
|
}]
|
|
178
243
|
};
|
|
@@ -181,13 +246,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
181
246
|
const start = Date.now();
|
|
182
247
|
const capMs = 9e4;
|
|
183
248
|
while (Date.now() - start < capMs) {
|
|
184
|
-
|
|
249
|
+
let step;
|
|
250
|
+
try {
|
|
251
|
+
step = await pairStep(device_code, kf);
|
|
252
|
+
} catch (e) {
|
|
253
|
+
clearSurface();
|
|
254
|
+
throw e;
|
|
255
|
+
}
|
|
185
256
|
if (step.kind === "e2ee_aborted") {
|
|
186
257
|
deleteKeyFile();
|
|
187
258
|
kf = null;
|
|
188
259
|
await sleep(2e3);
|
|
189
260
|
continue;
|
|
190
261
|
}
|
|
262
|
+
if (step.kind === "awaiting_confirm") {
|
|
263
|
+
return awaitingConfirmResult(step.sas, device_code);
|
|
264
|
+
}
|
|
191
265
|
if (step.kind === "paired") {
|
|
192
266
|
saveToken(step.token);
|
|
193
267
|
if (!step.sas || !kf) {
|
|
@@ -246,6 +320,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
246
320
|
}
|
|
247
321
|
case "notify_user": {
|
|
248
322
|
const parsed = NotifyRequestSchema.parse(request.params.arguments);
|
|
323
|
+
const problems = lintNotify(parsed);
|
|
324
|
+
if (problems.length) {
|
|
325
|
+
return {
|
|
326
|
+
isError: true,
|
|
327
|
+
content: [{ type: "text", text: JSON.stringify({ error: "fix_and_retry", problems }) }]
|
|
328
|
+
};
|
|
329
|
+
}
|
|
249
330
|
const git = detectGit();
|
|
250
331
|
const enriched = {
|
|
251
332
|
...parsed,
|
package/dist/listen.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
|
|
2
3
|
import {
|
|
3
4
|
checkReplies,
|
|
4
5
|
registerDelivery
|
|
5
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-OSQ3UXY6.js";
|
|
6
7
|
import {
|
|
7
8
|
WAKE_EVENT,
|
|
8
9
|
wakeChannel
|
|
9
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-WGOGS2JC.js";
|
|
10
11
|
|
|
11
12
|
// src/listen.ts
|
|
12
13
|
import { createClient } from "@supabase/supabase-js";
|
package/dist/onboard.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
|
|
2
3
|
import {
|
|
3
4
|
AGENT_NAME,
|
|
4
5
|
TOKEN_PATH,
|
|
@@ -7,7 +8,7 @@ import {
|
|
|
7
8
|
requestCode,
|
|
8
9
|
saveToken,
|
|
9
10
|
sleep
|
|
10
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-WGOGS2JC.js";
|
|
11
12
|
|
|
12
13
|
// src/onboard.ts
|
|
13
14
|
async function main() {
|
package/dist/statusline.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
|
|
2
3
|
import {
|
|
3
4
|
BACKEND_URL,
|
|
5
|
+
readSurface,
|
|
4
6
|
readToken
|
|
5
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-WGOGS2JC.js";
|
|
6
8
|
|
|
7
9
|
// src/statusline.ts
|
|
8
10
|
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "fs";
|
|
@@ -17,7 +19,8 @@ var MODE_LABEL = {
|
|
|
17
19
|
all_calls: "all calls",
|
|
18
20
|
silent: "silent"
|
|
19
21
|
};
|
|
20
|
-
function render(status) {
|
|
22
|
+
function render(status, surface = null) {
|
|
23
|
+
if (surface) return `paigy \u26A0 ${surface.label}: ${surface.code}`;
|
|
21
24
|
if (!status) return "paigy: unpaired";
|
|
22
25
|
return `paigy: ${MODE_LABEL[status.sessionMode]} \xB7 ${status.phone ? "phone \u2713" : "no phone"}`;
|
|
23
26
|
}
|
|
@@ -43,6 +46,8 @@ async function fetchStatus(token) {
|
|
|
43
46
|
return await res.json();
|
|
44
47
|
}
|
|
45
48
|
async function main() {
|
|
49
|
+
const surface = readSurface();
|
|
50
|
+
if (surface) return render(null, surface);
|
|
46
51
|
const token = readToken();
|
|
47
52
|
if (!token) return render(null);
|
|
48
53
|
const cached = readCache();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paigy/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
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",
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
"@paigy/schema": "0.0.0"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
|
-
"build": "tsup
|
|
43
|
-
"dev": "tsup
|
|
42
|
+
"build": "tsup",
|
|
43
|
+
"dev": "tsup --watch",
|
|
44
44
|
"typecheck": "tsc --noEmit",
|
|
45
45
|
"test": "vitest run"
|
|
46
46
|
}
|