openmausbot 0.1.93 → 0.1.94
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/assets/{DesktopViewer-CKNR3SEW.js → DesktopViewer-bxM7NV3N.js} +1 -1
- package/dist/assets/{abnfDiagram-O67JEVCF-Ckrm1I1X.js → abnfDiagram-O67JEVCF-BhMdJhRJ.js} +1 -1
- package/dist/assets/{arc-BBdBuCgB.js → arc-B5itKDcx.js} +1 -1
- package/dist/assets/{architectureDiagram-NJMV4G6O-CDdA3Hjs.js → architectureDiagram-NJMV4G6O-C5d7UY6U.js} +1 -1
- package/dist/assets/{blockDiagram-BEXU5L5S-AUrsx1YR.js → blockDiagram-BEXU5L5S-BrZIhMmu.js} +1 -1
- package/dist/assets/{c4Diagram-YGBWAQC7-BEqQ0Unh.js → c4Diagram-YGBWAQC7-7I0ArfbD.js} +1 -1
- package/dist/assets/channel-CdJX6i8k.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-CEEuvd4C.js → chunk-2Q5K7J3B-BnEsyN36.js} +1 -1
- package/dist/assets/{chunk-3FUC2YCW-DyANCiHl.js → chunk-3FUC2YCW-BXATQ52s.js} +2 -2
- package/dist/assets/{chunk-5VM5RSS4-Dx_Slpw9.js → chunk-5VM5RSS4-B0BpTkEx.js} +1 -1
- package/dist/assets/{chunk-GTNCS2PH-Dl7h7iiD.js → chunk-GTNCS2PH-BtLTz6FR.js} +1 -1
- package/dist/assets/{chunk-GWA4HPMP-BLx07SPb.js → chunk-GWA4HPMP-D6gcZOYt.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-BMxDj3n7.js → chunk-JWPE2WC7-DCLrosvf.js} +1 -1
- package/dist/assets/{chunk-NETBCI7D-CN66PT4H.js → chunk-NETBCI7D-B9JsknV9.js} +1 -1
- package/dist/assets/{chunk-WEXAMYUT-6HwhsDKw.js → chunk-WEXAMYUT-CsoTxw7z.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-gWnLt7SN.js → chunk-XXDRQBXY-0F_frB0c.js} +1 -1
- package/dist/assets/{classDiagram-v2-NBCMYWYE-CMS5l2ea.js → classDiagram-v2-NBCMYWYE-DkrpnflX.js} +1 -1
- package/dist/assets/{cose-bilkent-JH36ORCC-CV7sQwIG.js → cose-bilkent-JH36ORCC-BNDwYBt9.js} +1 -1
- package/dist/assets/{cynefin-OW5HDTMX-CSyl-7vl.js → cynefin-OW5HDTMX-JUkNr1Mk.js} +1 -1
- package/dist/assets/{cynefinDiagram-VND7K2PF-BCLKL6az.js → cynefinDiagram-VND7K2PF-DKeh9N4M.js} +1 -1
- package/dist/assets/{dagre-6A5THRUB-DiaZs6S1.js → dagre-6A5THRUB-ve-6T_wX.js} +1 -1
- package/dist/assets/{diagram-22UHCM2B-BeTtmt3I.js → diagram-22UHCM2B-BuJ2d8rT.js} +1 -1
- package/dist/assets/{diagram-3UASUU5V-CsdBG-QQ.js → diagram-3UASUU5V-BgOs_Ni-.js} +1 -1
- package/dist/assets/{diagram-ATOU4E4O-BczvdbR1.js → diagram-ATOU4E4O-DMK6VmcP.js} +1 -1
- package/dist/assets/{diagram-CDSNMT55-DHTTTMhi.js → diagram-CDSNMT55-DksASlcY.js} +1 -1
- package/dist/assets/{diagram-MLGK6HIB-RTjHyd8z.js → diagram-MLGK6HIB-Bgn8I5k6.js} +1 -1
- package/dist/assets/{diagram-MPIPVDR6-BDenHEH_.js → diagram-MPIPVDR6-zK0jqO8N.js} +1 -1
- package/dist/assets/{ebnfDiagram-ZINNZB2B-BSiRSbtL.js → ebnfDiagram-ZINNZB2B-vKrbzTwr.js} +1 -1
- package/dist/assets/{elk-276RUBZZ-BCAElss-.js → elk-276RUBZZ-Bzv2mPNf.js} +1 -1
- package/dist/assets/{erDiagram-OPXOYQCR-C4_1ZPJe.js → erDiagram-OPXOYQCR-CvOyYuxu.js} +1 -1
- package/dist/assets/{flowDiagram-KWPJA3E3-Bcb4Z75S.js → flowDiagram-KWPJA3E3-CtbnDbc0.js} +1 -1
- package/dist/assets/{ganttDiagram-FUAMR5RP-BiPOzxzb.js → ganttDiagram-FUAMR5RP-c4dMRYyG.js} +1 -1
- package/dist/assets/{gitGraphDiagram-X574FWY7-B1wtF3ZS.js → gitGraphDiagram-X574FWY7-B34uaQho.js} +1 -1
- package/dist/assets/index-CPYrGsv1.js +610 -0
- package/dist/assets/{index-BkYwilkG.js → index-CPiqc_DN.js} +1 -1
- package/dist/assets/index-CzT42GR8.css +1 -0
- package/dist/assets/{infoDiagram-VRGFBTTK-A19No71U.js → infoDiagram-VRGFBTTK-o64ma4CI.js} +1 -1
- package/dist/assets/{ishikawaDiagram-OU5B5YK6-BQsqJ_cV.js → ishikawaDiagram-OU5B5YK6-DV4C9BAm.js} +1 -1
- package/dist/assets/{journeyDiagram-ZHPQQLJL-DAO0pVan.js → journeyDiagram-ZHPQQLJL-Bf0AHk1H.js} +1 -1
- package/dist/assets/{kanban-definition-PNTS6WVX-NepmKEe2.js → kanban-definition-PNTS6WVX-B1UMSKmE.js} +1 -1
- package/dist/assets/{linear-bq1PTDTW.js → linear-BpKhuyLm.js} +1 -1
- package/dist/assets/{mermaid.core-s4LTbQle.js → mermaid.core-B_BKNCw5.js} +5 -5
- package/dist/assets/{mindmap-definition-NLK3R4M7-LtdbgHFH.js → mindmap-definition-NLK3R4M7-DeARruT1.js} +1 -1
- package/dist/assets/{pegDiagram-GJSIUBJH-tdbaCxiK.js → pegDiagram-GJSIUBJH-BOQEk_1D.js} +1 -1
- package/dist/assets/{pieDiagram-5QR66LMP-DvkbpW8l.js → pieDiagram-5QR66LMP-EIfoiTZo.js} +1 -1
- package/dist/assets/{quadrantDiagram-O4NWA36T-Do4KaH-E.js → quadrantDiagram-O4NWA36T-GgZwT-fi.js} +1 -1
- package/dist/assets/{railroadDiagram-XR7U4H2S-BQWULOAs.js → railroadDiagram-XR7U4H2S-D9rDQomj.js} +1 -1
- package/dist/assets/{requirementDiagram-PLB6GJNP-w9kei1FU.js → requirementDiagram-PLB6GJNP-DAh1MIeo.js} +1 -1
- package/dist/assets/{sankeyDiagram-IPEJSGJF-D2gRy0e8.js → sankeyDiagram-IPEJSGJF-CPr6Uz64.js} +1 -1
- package/dist/assets/{sequenceDiagram-PO4LG4MO-a2sO5_Eo.js → sequenceDiagram-PO4LG4MO-BU6VIdcm.js} +1 -1
- package/dist/assets/{sizeCapture-INFHLROL-CiPJmFEB.js → sizeCapture-INFHLROL-qF2HO41r.js} +1 -1
- package/dist/assets/{stateDiagram-v2-GCMORJYK-DAkUUlL7.js → stateDiagram-v2-GCMORJYK-DlJ8G4MD.js} +1 -1
- package/dist/assets/{swimlanes-2SLR337P-DANf2R8Y.js → swimlanes-2SLR337P-DzYE8I54.js} +1 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-D-U4GtLQ.js +8 -0
- package/dist/assets/{timeline-definition-EJHVYXUP-BKpQmzrT.js → timeline-definition-EJHVYXUP-B7wzEQ0A.js} +1 -1
- package/dist/assets/{usecaseDiagram-POWQR4AR-BtI7qLN-.js → usecaseDiagram-POWQR4AR-BvGgrxIP.js} +1 -1
- package/dist/assets/{vennDiagram-UO4OBE2U-gXJXrAG_.js → vennDiagram-UO4OBE2U-CEECdgQg.js} +1 -1
- package/dist/assets/{wardleyDiagram-VNRHLVJA-GU8Po5-n.js → wardleyDiagram-VNRHLVJA-XQHA0P_P.js} +1 -1
- package/dist/assets/{xychartDiagram-PMCCYNJV-BF4J30g6.js → xychartDiagram-PMCCYNJV-B5mLcZI2.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/cloud-home-start.js +1 -0
- package/dist-server/companion/src/routes.js +21 -0
- package/dist-server/container-mcp.js +93 -76
- package/dist-server/drivers/agents-proxy.js +38 -1
- package/dist-server/drivers/pi-mcp-extension.ts +245 -299
- package/dist-server/enterprise/server/index.js +1 -0
- package/dist-server/index.js +16942 -13191
- package/dist-server/local-computer.js +1 -0
- package/dist-server/mcp-gate.js +147 -19
- package/dist-server/mcp-remote-proxy.js +437 -0
- package/dist-server/openmausbot.js +8233 -7651
- package/dist-server/pair-cli.js +8233 -7651
- package/dist-server/permission-proxy.js +65 -1
- package/dist-server/proxy-paths.js +1 -0
- package/dist-server/server/auto-approve.js +33 -11
- package/dist-server/server/browser-live.js +14 -5
- package/dist-server/server/browser-runtime.js +6 -0
- package/dist-server/server/channel-queue.js +4 -1
- package/dist-server/server/cli.js +32 -12
- package/dist-server/server/cloud-move-http.js +9 -3
- package/dist-server/server/cloud-move.js +18 -0
- package/dist-server/server/computer-wait.js +33 -13
- package/dist-server/server/config.js +44 -10
- package/dist-server/server/decision-log.js +9 -6
- package/dist-server/server/delegations.js +47 -22
- package/dist-server/server/drivers/acp/core.js +57 -8
- package/dist-server/server/drivers/acp/grok.js +208 -3
- package/dist-server/server/drivers/agents-call.js +28 -0
- package/dist-server/server/drivers/agents-catalog.js +10 -1
- package/dist-server/server/drivers/boatagent.js +2 -0
- package/dist-server/server/drivers/chat-mcp-tools.js +27 -14
- package/dist-server/server/drivers/claude.js +18 -6
- package/dist-server/server/drivers/codex.js +84 -10
- package/dist-server/server/drivers/openai-chat.js +11 -3
- package/dist-server/server/drivers/pi.js +48 -4
- package/dist-server/server/environment.js +3 -0
- package/dist-server/server/guarded-requests.js +2 -1
- package/dist-server/server/index.js +1143 -254
- package/dist-server/server/live-call-controller.js +868 -0
- package/dist-server/server/live-call.js +216 -0
- package/dist-server/server/mcp-gate-config.js +43 -12
- package/dist-server/server/mcp-gate.js +127 -25
- package/dist-server/server/mcp-http.js +46 -8
- package/dist-server/server/mcp-remote-proxy.js +108 -0
- package/dist-server/server/new-bot-defaults.js +3 -0
- package/dist-server/server/peer-provenance.js +4 -2
- package/dist-server/server/permission-proxy.js +15 -1
- package/dist-server/server/plan-usage.js +797 -0
- package/dist-server/server/proxy-paths.js +1 -0
- package/dist-server/server/redact.js +2 -0
- package/dist-server/server/request-auth.js +3 -2
- package/dist-server/server/routes/live.js +98 -0
- package/dist-server/server/skill-library.js +207 -0
- package/dist-server/server/skills-library-migration.js +235 -0
- package/dist-server/server/skills.js +66 -3
- package/dist-server/server/steer-queue.js +13 -1
- package/dist-server/server/store.js +32 -4
- package/dist-server/server/surface.js +9 -3
- package/dist-server/server/team-backup.js +2 -1
- package/dist-server/server/thread-events.js +1 -1
- package/dist-server/server/tunnel.js +20 -1
- package/dist-server/server/turn-log.js +10 -0
- package/dist-server/server/turn-resources.js +124 -0
- package/dist-server/shared/ask-question.js +13 -0
- package/dist-server/shared/call-consent.js +28 -0
- package/dist-server/shared/live-approval.js +124 -0
- package/dist-server/shared/live-call.js +176 -0
- package/dist-server/shared/skill-md.js +10 -0
- package/dist-server/shared/team-backup.js +3 -0
- package/dist-server/shared/tool-scope-support.js +25 -0
- package/dist-server/shared/tool-scope.js +136 -0
- package/dist-server/src/lib/thread-refs.js +406 -0
- package/dist-server/vps-container-mcp.js +93 -76
- package/dist-server/workspace-backup.worker.js +89 -100
- package/enterprise/server/index.js +1 -0
- package/package.json +1 -1
- package/dist/assets/channel-BU94VpxE.js +0 -1
- package/dist/assets/index-Biji-bfL.css +0 -1
- package/dist/assets/index-DZlhoES0.js +0 -597
- package/dist/assets/swimlanesDiagram-TC7HE7FX-CdHxG8aM.js +0 -8
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/** "approval": a provider or peer ask the voice may decide with a strict yes/no.
|
|
2
|
+
* "review": a harness-native proposal (skill, routine, profile, default
|
|
3
|
+
* model, tightening, team setup) that must be reviewed on screen.
|
|
4
|
+
* "question": a provider question. An expired proposal is settled: nothing
|
|
5
|
+
* can answer it any more. */
|
|
6
|
+
export function liveCardKind(card) {
|
|
7
|
+
if (!card?.requestId || card.answered || card.dismissed || card.expired)
|
|
8
|
+
return null;
|
|
9
|
+
if (card.skillRequest || card.routineRequest || card.profileRequest || card.modelRequest || card.tighteningRequest || card.teamSetupRequest) {
|
|
10
|
+
return "review";
|
|
11
|
+
}
|
|
12
|
+
return card.tool ? "approval" : "question";
|
|
13
|
+
}
|
|
14
|
+
/** Why a decision made on a Live call must not reach this card, or null to
|
|
15
|
+
* deliver it. A card settled a moment earlier (answered or dismissed on
|
|
16
|
+
* screen) is refused before anything runs: delivering to it would add a
|
|
17
|
+
* false "the action was not run" line after the tap did run it. A card that
|
|
18
|
+
* is not on the thread is left to the normal answer path. */
|
|
19
|
+
export function liveDecisionRefusal(card) {
|
|
20
|
+
if (!card)
|
|
21
|
+
return null;
|
|
22
|
+
const kind = liveCardKind(card);
|
|
23
|
+
if (kind === "review")
|
|
24
|
+
return "This request is reviewed on screen.";
|
|
25
|
+
return kind ? null : "The request is no longer open.";
|
|
26
|
+
}
|
|
27
|
+
const TOOL_PHRASES = {
|
|
28
|
+
Bash: "run a command",
|
|
29
|
+
Read: "read a file",
|
|
30
|
+
Write: "write a file",
|
|
31
|
+
Edit: "edit a file",
|
|
32
|
+
WebFetch: "fetch a web page",
|
|
33
|
+
WebSearch: "search the web",
|
|
34
|
+
schedule_routine: "schedule a routine",
|
|
35
|
+
manage_routine: "change a routine",
|
|
36
|
+
stage_skill: "enable a learned skill",
|
|
37
|
+
update_skill: "update a learned skill",
|
|
38
|
+
update_profile: "update its profile",
|
|
39
|
+
shell: "run a command",
|
|
40
|
+
edit: "edit a file",
|
|
41
|
+
read: "read a file",
|
|
42
|
+
fetch: "fetch a web page",
|
|
43
|
+
delete: "delete a file",
|
|
44
|
+
think: "think",
|
|
45
|
+
other: "take an action",
|
|
46
|
+
tool: "use a tool",
|
|
47
|
+
};
|
|
48
|
+
/** Same rule as toolLabel in src/components/ApprovalCard.tsx, in English. */
|
|
49
|
+
export function toolPhrase(tool) {
|
|
50
|
+
if (!tool)
|
|
51
|
+
return "take an action";
|
|
52
|
+
return TOOL_PHRASES[tool] ?? tool.replace(/^mcp__[^_]+__/, "").replace(/_/g, " ");
|
|
53
|
+
}
|
|
54
|
+
const DETAIL_LIMIT = 400;
|
|
55
|
+
function duration(ms) {
|
|
56
|
+
const seconds = Math.max(0, Math.round(ms / 1_000));
|
|
57
|
+
if (seconds < 60)
|
|
58
|
+
return `${seconds} second${seconds === 1 ? "" : "s"}`;
|
|
59
|
+
const minutes = Math.round(seconds / 60);
|
|
60
|
+
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
|
|
61
|
+
}
|
|
62
|
+
/** A bot's step as the voice may name it: the tool's own spoken label, or
|
|
63
|
+
* the tool's name ("team notes: query database") — never its arguments,
|
|
64
|
+
* which can hold paths, queries or private text. */
|
|
65
|
+
export function liveStepLabel(tool) {
|
|
66
|
+
if (tool.spoken?.trim())
|
|
67
|
+
return brief(tool.spoken, 80);
|
|
68
|
+
const parts = tool.name.split("__");
|
|
69
|
+
if (parts[0] === "mcp" && parts.length >= 3) {
|
|
70
|
+
const server = parts[1].replace(/[-_]+/g, " ").trim();
|
|
71
|
+
const action = parts.slice(2).join(" ").replace(/_+/g, " ").trim();
|
|
72
|
+
return brief(`${server}: ${action}`, 80);
|
|
73
|
+
}
|
|
74
|
+
return toolPhrase(tool.name);
|
|
75
|
+
}
|
|
76
|
+
function brief(text, limit = DETAIL_LIMIT) {
|
|
77
|
+
const flat = text.replace(/\s+/g, " ").trim().replace(/[.!?]+$/, "");
|
|
78
|
+
return flat.length <= limit ? flat : `${flat.slice(0, limit).replace(/\s+\S*$/, "")}…`;
|
|
79
|
+
}
|
|
80
|
+
// The voice IS the bot to the person on the call, so everything below is
|
|
81
|
+
// either said as the bot ("I …") or addressed to the voice as "you". Never
|
|
82
|
+
// name the bot in the third person: the voice then talks about itself as a
|
|
83
|
+
// separate "backend" (seen in the first test calls).
|
|
84
|
+
export function spokenApprovalPrompt(card) {
|
|
85
|
+
const detail = brief(card.subtitle);
|
|
86
|
+
return `I want to ${toolPhrase(card.tool)}.${detail ? ` ${detail}.` : ""} May I?`;
|
|
87
|
+
}
|
|
88
|
+
export function spokenReviewPrompt(card) {
|
|
89
|
+
const title = brief(card.title || "a proposal", 200);
|
|
90
|
+
return `Tell the user, in your own words, that you need their decision in the chat: ${title}. They review it on screen and choose there; it cannot be decided by voice.`;
|
|
91
|
+
}
|
|
92
|
+
/** The bot waits for an app to be connected in the chat (a connector card). */
|
|
93
|
+
export function spokenConnectorPrompt(label) {
|
|
94
|
+
return `Tell the user, in your own words, that you need them to connect ${brief(label || "an app", 80)} in the chat before you can go on. It cannot be done by voice.`;
|
|
95
|
+
}
|
|
96
|
+
/** The bot waits for a credential entered on screen (a secret card). It must
|
|
97
|
+
* never travel through the call, so the voice never asks for it aloud. */
|
|
98
|
+
export function spokenSecretPrompt(label) {
|
|
99
|
+
return `Tell the user, in your own words, that you need ${brief(label || "a credential", 80)} entered in the chat on screen before you can go on. Never ask them to say it aloud.`;
|
|
100
|
+
}
|
|
101
|
+
export function spokenQuestionPrompt(card) {
|
|
102
|
+
const detail = brief(card.subtitle || card.title);
|
|
103
|
+
const choices = card.options.length ? ` The options are ${card.options.join(", ")}.` : "";
|
|
104
|
+
// brief() drops the detail's own closing mark, so the "?" is never doubled
|
|
105
|
+
return `Ask the user, in your own words: ${detail}?${choices} Then delegate their answer.`;
|
|
106
|
+
}
|
|
107
|
+
export const LIVE_COPY = {
|
|
108
|
+
notHeard: "The request was not heard clearly. Ask the user to say it again.",
|
|
109
|
+
notClear: "That was not a clear yes or no. Ask the user again for a clear yes or no.",
|
|
110
|
+
deniedMessage: "Denied by the user, on a live call.",
|
|
111
|
+
answeredInChat: "The user answered that request in the chat. Do not ask about it again.",
|
|
112
|
+
working: "You are working on the request now. Nothing is done until the result comes back; do not guess it.",
|
|
113
|
+
answerPassed: "You are using the user's answer now. Wait for the result.",
|
|
114
|
+
granted: "Thanks, I'll go ahead.",
|
|
115
|
+
denied: "Okay, I won't do it.",
|
|
116
|
+
saveFailed: (detail) => `The decision could not be saved${detail ? `: ${detail}` : "."} Ask the user to try again.`,
|
|
117
|
+
noAnswer: "I'm done. The result, or what went wrong, is in the chat.",
|
|
118
|
+
typedAnswerLead: (typed) => `About what you typed in the chat ("${brief(typed, 200)}"):`,
|
|
119
|
+
progress: (spoken) => `Progress: ${brief(spoken, 200)}`,
|
|
120
|
+
status: (workedMs, steps, lastStep, lastStepAgoMs) => `Status note, do not announce it: you are still working on it (${duration(workedMs)} so far, ${steps} step${steps === 1 ? "" : "s"}${lastStep ? `; last step: ${lastStep}, ${duration(lastStepAgoMs)} ago` : ""}).`,
|
|
121
|
+
signedOut: "The call has ended because the sign-in that started it has ended. Sign in again to start a new call.",
|
|
122
|
+
unpaired: "The call has ended because the phone that started it was unpaired from this computer.",
|
|
123
|
+
permissionRequest: (prompt) => `You need the user's permission. Say it in your own words: ${prompt} Ask for a clear yes or no, then delegate their answer.`,
|
|
124
|
+
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Pure pieces of a Live (GPT-Live) call, kept out of the component so they
|
|
2
|
+
// can be tested without WebRTC.
|
|
3
|
+
//
|
|
4
|
+
// With client delegation GPT-Live says *that* it wants help, never *what*:
|
|
5
|
+
// `session.delegation.created` carries an id and a timeline offset, and the
|
|
6
|
+
// request has to be rebuilt from the input transcript. That reconstruction,
|
|
7
|
+
// and the shaping of the bot's answer into the 500-token appends GPT-Live
|
|
8
|
+
// accepts, live here.
|
|
9
|
+
/** Collects `session.input_transcript.delta` fragments on the session
|
|
10
|
+
* timeline. Fragments are appended in delivery order, as the API asks. */
|
|
11
|
+
export class LiveTranscript {
|
|
12
|
+
input = [];
|
|
13
|
+
/** when the voice itself was speaking, on the same timeline */
|
|
14
|
+
output = [];
|
|
15
|
+
cutoffMs = -1;
|
|
16
|
+
addInput(text, startMs, endMs) {
|
|
17
|
+
if (!text)
|
|
18
|
+
return;
|
|
19
|
+
this.input.push({ text, startMs: Number.isFinite(startMs) ? startMs : 0, endMs: Number.isFinite(endMs) ? endMs : 0 });
|
|
20
|
+
// keep memory bounded on long calls: only the unconsumed tail matters
|
|
21
|
+
if (this.input.length > 2_000)
|
|
22
|
+
this.input = this.input.filter((segment) => segment.startMs > this.cutoffMs);
|
|
23
|
+
}
|
|
24
|
+
/** A stretch of the voice's own speech (a `session.output_transcript.delta`
|
|
25
|
+
* with its timing). Input heard during it may be the voice itself, picked
|
|
26
|
+
* up again by the microphone of a phone on speaker. Deltas without timing
|
|
27
|
+
* are ignored. */
|
|
28
|
+
addOutput(startMs, endMs) {
|
|
29
|
+
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs)
|
|
30
|
+
return;
|
|
31
|
+
const last = this.output.at(-1);
|
|
32
|
+
if (last && startMs <= last.endMs)
|
|
33
|
+
last.endMs = Math.max(last.endMs, endMs);
|
|
34
|
+
else
|
|
35
|
+
this.output.push({ startMs, endMs });
|
|
36
|
+
if (this.output.length > 500)
|
|
37
|
+
this.output.splice(0, this.output.length - 500);
|
|
38
|
+
}
|
|
39
|
+
/** The words spoken since the previous request, up to a little past the
|
|
40
|
+
* delegation point (transcription can trail the delegation event, and the
|
|
41
|
+
* person may finish the sentence that triggered it). Consumes them, so the
|
|
42
|
+
* next request starts after this one. */
|
|
43
|
+
takeRequest(offsetMs, graceMs = 1_500) {
|
|
44
|
+
return this.takeRequestParts(offsetMs, graceMs).text;
|
|
45
|
+
}
|
|
46
|
+
/** takeRequest, plus the same words without those heard while the voice
|
|
47
|
+
* spoke (`withoutEcho`), for reading a spoken yes/no. Consumes all of them. */
|
|
48
|
+
takeRequestParts(offsetMs, graceMs = 1_500) {
|
|
49
|
+
const until = offsetMs + graceMs;
|
|
50
|
+
const taken = this.input.filter((segment) => segment.startMs > this.cutoffMs && segment.startMs <= until);
|
|
51
|
+
if (!taken.length)
|
|
52
|
+
return { text: "", withoutEcho: "" };
|
|
53
|
+
this.cutoffMs = Math.max(this.cutoffMs, ...taken.map((segment) => segment.startMs));
|
|
54
|
+
return {
|
|
55
|
+
text: joinFragments(taken.map((segment) => segment.text)),
|
|
56
|
+
withoutEcho: joinFragments(taken.filter((segment) => !this.echoed(segment)).map((segment) => segment.text)),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Everything heard since the last taken request, without consuming it —
|
|
60
|
+
* used to read a spoken yes/no while a permission question is open.
|
|
61
|
+
* `skipEcho` leaves out what was heard while the voice itself spoke. */
|
|
62
|
+
pending(options = {}) {
|
|
63
|
+
return joinFragments(this.input
|
|
64
|
+
.filter((segment) => segment.startMs > this.cutoffMs && !(options.skipEcho && this.echoed(segment)))
|
|
65
|
+
.map((segment) => segment.text));
|
|
66
|
+
}
|
|
67
|
+
echoed(segment) {
|
|
68
|
+
return this.output.some((span) => segment.startMs < span.endMs && segment.endMs > span.startMs);
|
|
69
|
+
}
|
|
70
|
+
/** Mark everything heard so far as handled (e.g. after a spoken decision). */
|
|
71
|
+
consumeAll() {
|
|
72
|
+
for (const segment of this.input)
|
|
73
|
+
this.cutoffMs = Math.max(this.cutoffMs, segment.startMs);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Transcript deltas are fragments of words, not words: glue them as sent. */
|
|
77
|
+
export function joinFragments(fragments) {
|
|
78
|
+
return fragments.join("").replace(/\s+/g, " ").trim();
|
|
79
|
+
}
|
|
80
|
+
/** GPT-Live caps each append at 500 tokens. A 500 UTF-8 byte budget is
|
|
81
|
+
* conservative even for emoji/CJK, unlike a prose-only character estimate.
|
|
82
|
+
* Byte-level tokenization cannot produce more tokens than input bytes. */
|
|
83
|
+
const APPEND_BYTE_LIMIT = 500;
|
|
84
|
+
const encoder = new TextEncoder();
|
|
85
|
+
const bytes = (text) => encoder.encode(text).length;
|
|
86
|
+
/** Standard UTF-8 accounting; stop at a complete code point, not a surrogate. */
|
|
87
|
+
function bytePrefix(text, budget) {
|
|
88
|
+
return new TextDecoder().decode(encoder.encode(text).subarray(0, budget), { stream: true });
|
|
89
|
+
}
|
|
90
|
+
const MAX_SPOKEN_CHUNKS = 3;
|
|
91
|
+
export const FULL_ANSWER_IN_CHAT = "The full answer is in the chat.";
|
|
92
|
+
/** Group speakable utterances (from /api/tts/prepare) into appends. Long
|
|
93
|
+
* answers are cut at a sentence boundary with a pointer to the chat, because
|
|
94
|
+
* a voice reading four minutes of a report is not a conversation. */
|
|
95
|
+
export function commentaryChunks(utterances) {
|
|
96
|
+
const chunks = [];
|
|
97
|
+
let current = "";
|
|
98
|
+
let truncated = false;
|
|
99
|
+
for (const raw of utterances) {
|
|
100
|
+
const utterance = raw.replace(/\s+/g, " ").trim();
|
|
101
|
+
if (!utterance)
|
|
102
|
+
continue;
|
|
103
|
+
const pieces = bytes(utterance) > APPEND_BYTE_LIMIT ? splitLong(utterance) : [utterance];
|
|
104
|
+
for (const piece of pieces) {
|
|
105
|
+
if (current && bytes(current) + 1 + bytes(piece) > APPEND_BYTE_LIMIT) {
|
|
106
|
+
chunks.push(current);
|
|
107
|
+
current = "";
|
|
108
|
+
}
|
|
109
|
+
if (chunks.length >= MAX_SPOKEN_CHUNKS) {
|
|
110
|
+
truncated = true;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
current = current ? `${current} ${piece}` : piece;
|
|
114
|
+
}
|
|
115
|
+
if (truncated)
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
if (current && chunks.length < MAX_SPOKEN_CHUNKS)
|
|
119
|
+
chunks.push(current);
|
|
120
|
+
else if (current)
|
|
121
|
+
truncated = true;
|
|
122
|
+
if (truncated && chunks.length) {
|
|
123
|
+
const last = chunks[chunks.length - 1];
|
|
124
|
+
chunks[chunks.length - 1] = bytes(last) + 1 + bytes(FULL_ANSWER_IN_CHAT) <= APPEND_BYTE_LIMIT
|
|
125
|
+
? `${last} ${FULL_ANSWER_IN_CHAT}`
|
|
126
|
+
: `${bytePrefix(last, APPEND_BYTE_LIMIT - bytes(FULL_ANSWER_IN_CHAT) - 1).replace(/\s+\S*$/, "")} ${FULL_ANSWER_IN_CHAT}`;
|
|
127
|
+
}
|
|
128
|
+
return chunks;
|
|
129
|
+
}
|
|
130
|
+
function splitLong(text) {
|
|
131
|
+
const out = [];
|
|
132
|
+
let rest = text;
|
|
133
|
+
while (bytes(rest) > APPEND_BYTE_LIMIT) {
|
|
134
|
+
const window = bytePrefix(rest, APPEND_BYTE_LIMIT);
|
|
135
|
+
const sentence = Math.max(window.lastIndexOf(". "), window.lastIndexOf("! "), window.lastIndexOf("? "));
|
|
136
|
+
const space = window.lastIndexOf(" ");
|
|
137
|
+
const cut = sentence > window.length / 2 ? sentence + 1 : space > 0 ? space : window.length;
|
|
138
|
+
out.push(rest.slice(0, cut).trim());
|
|
139
|
+
rest = rest.slice(cut).trim();
|
|
140
|
+
}
|
|
141
|
+
if (rest)
|
|
142
|
+
out.push(rest);
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
/** Keep appended content inside the per-append limit. */
|
|
146
|
+
export function clampAppend(content) {
|
|
147
|
+
const text = content.replace(/\s+/g, " ").trim();
|
|
148
|
+
return bytes(text) <= APPEND_BYTE_LIMIT ? text : `${bytePrefix(text, APPEND_BYTE_LIMIT - bytes("…")).replace(/\s+\S*$/, "")}…`;
|
|
149
|
+
}
|
|
150
|
+
/** GPT-Live voices from OpenAI's documentation (Managing GPT-Live sessions,
|
|
151
|
+
* "Voice options", and the SDK's BuiltInVoice list), with the regional
|
|
152
|
+
* notes the docs give for the additional ones. */
|
|
153
|
+
export const LIVE_VOICE_OPTIONS = [
|
|
154
|
+
{ id: "marin", label: "Marin (default)" },
|
|
155
|
+
{ id: "cedar", label: "Cedar" },
|
|
156
|
+
{ id: "alloy", label: "Alloy" },
|
|
157
|
+
{ id: "ash", label: "Ash" },
|
|
158
|
+
{ id: "ballad", label: "Ballad" },
|
|
159
|
+
{ id: "coral", label: "Coral" },
|
|
160
|
+
{ id: "echo", label: "Echo" },
|
|
161
|
+
{ id: "sage", label: "Sage" },
|
|
162
|
+
{ id: "shimmer", label: "Shimmer" },
|
|
163
|
+
{ id: "verse", label: "Verse" },
|
|
164
|
+
{ id: "gleam", label: "Gleam — North American, feminine" },
|
|
165
|
+
{ id: "meridian", label: "Meridian — North American, masculine" },
|
|
166
|
+
{ id: "quartz", label: "Quartz — Australian, feminine" },
|
|
167
|
+
{ id: "ripple", label: "Ripple — Australian, masculine" },
|
|
168
|
+
{ id: "vesper", label: "Vesper — British, masculine" },
|
|
169
|
+
{ id: "willow", label: "Willow — Irish, feminine" },
|
|
170
|
+
{ id: "stone", label: "Stone — Irish, masculine" },
|
|
171
|
+
{ id: "delta", label: "Delta — Southern U.S., feminine" },
|
|
172
|
+
{ id: "cinder", label: "Cinder — Southern U.S., masculine" },
|
|
173
|
+
{ id: "beacon", label: "Beacon — Filipino, masculine" },
|
|
174
|
+
{ id: "bossa", label: "Bossa — Brazilian Portuguese, feminine" },
|
|
175
|
+
{ id: "tempo", label: "Tempo — Brazilian Portuguese, masculine" },
|
|
176
|
+
];
|
|
@@ -13,6 +13,15 @@ export const SKILL_FILE_MAX_BYTES = 256 * 1024;
|
|
|
13
13
|
export function isSkillName(name) {
|
|
14
14
|
return name.length >= 1 && name.length <= SKILL_NAME_MAX && SKILL_NAME.test(name);
|
|
15
15
|
}
|
|
16
|
+
/** Browsing tags: lowercase slug-ish words, short enough for chip rows. */
|
|
17
|
+
const TAG_PATTERN = /^[a-z0-9][a-z0-9-]{0,23}$/;
|
|
18
|
+
export const SKILL_TAGS_MAX = 8;
|
|
19
|
+
export function parseSkillTags(raw) {
|
|
20
|
+
if (!raw)
|
|
21
|
+
return undefined;
|
|
22
|
+
const tags = [...new Set(raw.split(",").map((tag) => tag.trim().toLowerCase()).filter((tag) => TAG_PATTERN.test(tag)))].slice(0, SKILL_TAGS_MAX);
|
|
23
|
+
return tags.length > 0 ? tags : undefined;
|
|
24
|
+
}
|
|
16
25
|
/** Minimal frontmatter reader for the two required keys plus the two we
|
|
17
26
|
* display. Deliberately not a YAML engine: values are single-line strings in
|
|
18
27
|
* every skill the spec's own examples show, and a parser that cannot
|
|
@@ -41,6 +50,7 @@ export function parseSkillMd(raw) {
|
|
|
41
50
|
description,
|
|
42
51
|
license: fields.license || undefined,
|
|
43
52
|
compatibility: fields.compatibility || undefined,
|
|
53
|
+
tags: parseSkillTags(fields.tags),
|
|
44
54
|
body: match[2] ?? "",
|
|
45
55
|
};
|
|
46
56
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { CONNECTOR_SLUG_PATTERN, CONNECTOR_TOOL_NAME_PATTERN } from "./wire.js";
|
|
3
|
+
import { parseToolScope } from "./tool-scope.js";
|
|
3
4
|
export const MAX_TEAM_BACKUP_BYTES = 50 * 1024 * 1024;
|
|
4
5
|
export const TEAM_BACKUP_CONTENTS = "Bot profiles, instructions, sections, rooms, playbooks, routines, each bot's memory (MEMORY.md, topic notes and daily logs) and conversation text (all tasks and branches).";
|
|
5
6
|
export const TEAM_BACKUP_EXCLUSIONS = "Files, images, custom avatars, account connections, model settings and permissions are not included. Action cards are saved as text. Memory is saved with secrets removed. Imported routines start paused.";
|
|
@@ -98,6 +99,8 @@ const backupSchema = z.object({
|
|
|
98
99
|
hidden: z.boolean(),
|
|
99
100
|
playbooks: z.array(playbook).max(200),
|
|
100
101
|
connectorTools: connectorTools.optional(),
|
|
102
|
+
toolScope: z.custom((value) => value !== undefined && parseToolScope(value).ok, "Invalid tool selection")
|
|
103
|
+
.transform((value) => { const parsed = parseToolScope(value); return parsed.ok ? parsed.scope : value; }).optional(),
|
|
101
104
|
memory: memory.optional(),
|
|
102
105
|
})).min(1).max(200),
|
|
103
106
|
groups: z.array(z.preprocess(knownRoomResponder, z.object({
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { narrowsNativeTools, parseToolScope } from "./tool-scope.js";
|
|
2
|
+
/** Native contracts and app-supplied MCP gates are separate capabilities. */
|
|
3
|
+
export const TOOL_SCOPE_SUPPORT = {
|
|
4
|
+
grok: "native-and-mcp", grokAgent: "native-and-mcp", piAgent: "native-and-mcp",
|
|
5
|
+
"openai-compat": "native-and-mcp", mistral: "native-and-mcp", minimax: "native-and-mcp", cerebras: "native-and-mcp",
|
|
6
|
+
claudeAgent: "mcp", codex: "mcp", geminiAgent: "mcp", kimiAgent: "mcp", droidAgent: "mcp",
|
|
7
|
+
cursorAgent: "mcp", opencodeGo: "mcp", qwenAgent: "mcp", hermesAgent: "mcp", customAcp: "mcp", antigravityAgent: "mcp",
|
|
8
|
+
boxAgent: "none",
|
|
9
|
+
};
|
|
10
|
+
export function assertToolScopeSupported(engine, value) {
|
|
11
|
+
const parsed = parseToolScope(value);
|
|
12
|
+
if (!parsed.ok)
|
|
13
|
+
throw new Error(parsed.error);
|
|
14
|
+
const support = TOOL_SCOPE_SUPPORT[engine];
|
|
15
|
+
if (parsed.scope !== undefined && support !== "native-and-mcp") {
|
|
16
|
+
if (support === "none" || support === undefined) {
|
|
17
|
+
if (parsed.scope.allow !== undefined || parsed.scope.deny?.length)
|
|
18
|
+
throw new Error(`${engine}: tool selection is not supported by this engine. Choose a supported engine.`);
|
|
19
|
+
}
|
|
20
|
+
else if (narrowsNativeTools(parsed.scope)) {
|
|
21
|
+
throw new Error(`${engine}: native tool selection is not supported by this engine. Keep native:* in the selection or choose a supported engine.`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return parsed.scope;
|
|
25
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
const SERVER_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
2
|
+
// Selectors must never contain protocol control characters.
|
|
3
|
+
// eslint-disable-next-line no-control-regex
|
|
4
|
+
const CONTROL_CHARACTER = /[\x00-\x1f\x7f]/;
|
|
5
|
+
const MAX_SELECTORS = 256;
|
|
6
|
+
const MAX_SELECTOR_LENGTH = 1024;
|
|
7
|
+
function validName(name) {
|
|
8
|
+
return typeof name === "string" && name.trim().length > 0
|
|
9
|
+
&& name.length <= MAX_SELECTOR_LENGTH && !CONTROL_CHARACTER.test(name)
|
|
10
|
+
&& !name.includes("*");
|
|
11
|
+
}
|
|
12
|
+
function selectorIdentity(selector) {
|
|
13
|
+
if (selector.length > MAX_SELECTOR_LENGTH || CONTROL_CHARACTER.test(selector))
|
|
14
|
+
return;
|
|
15
|
+
if (selector.startsWith("native:")) {
|
|
16
|
+
const name = selector.slice(7);
|
|
17
|
+
if (name === "*" || validName(name))
|
|
18
|
+
return { kind: "native", name };
|
|
19
|
+
}
|
|
20
|
+
else if (selector.startsWith("mcp:")) {
|
|
21
|
+
const separator = selector.indexOf(":", 4);
|
|
22
|
+
if (separator < 0)
|
|
23
|
+
return;
|
|
24
|
+
const server = selector.slice(4, separator);
|
|
25
|
+
const name = selector.slice(separator + 1);
|
|
26
|
+
if (SERVER_NAME.test(server) && (name === "*" || validName(name))) {
|
|
27
|
+
return { kind: "mcp", server, name };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Undefined inherits the existing catalog. A present invalid value never does. */
|
|
32
|
+
export function parseToolScope(value) {
|
|
33
|
+
if (value === undefined)
|
|
34
|
+
return { ok: true, scope: undefined };
|
|
35
|
+
const invalid = { ok: false, error: "Tool selection must contain only valid allow and deny lists." };
|
|
36
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
37
|
+
return invalid;
|
|
38
|
+
const prototype = Object.getPrototypeOf(value);
|
|
39
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
40
|
+
return invalid;
|
|
41
|
+
const scope = {};
|
|
42
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
43
|
+
if (key !== "allow" && key !== "deny")
|
|
44
|
+
return invalid;
|
|
45
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
46
|
+
if (!descriptor || !("value" in descriptor))
|
|
47
|
+
return invalid;
|
|
48
|
+
const list = descriptor.value;
|
|
49
|
+
if (!Array.isArray(list) || list.length > MAX_SELECTORS)
|
|
50
|
+
return invalid;
|
|
51
|
+
// Array.from exposes holes as undefined instead of silently skipping them.
|
|
52
|
+
if (!Array.from(list).every((entry) => typeof entry === "string" && selectorIdentity(entry)))
|
|
53
|
+
return invalid;
|
|
54
|
+
scope[key] = [...new Set(list)];
|
|
55
|
+
}
|
|
56
|
+
return { ok: true, scope };
|
|
57
|
+
}
|
|
58
|
+
function matches(selector, tool) {
|
|
59
|
+
const selected = selectorIdentity(selector);
|
|
60
|
+
return selected?.kind === tool.kind
|
|
61
|
+
&& (selected.kind !== "mcp" || (tool.kind === "mcp" && selected.server === tool.server))
|
|
62
|
+
&& (selected.name === "*" || selected.name === tool.name);
|
|
63
|
+
}
|
|
64
|
+
function permits(scope, tool) {
|
|
65
|
+
if (!validName(tool.name) || (tool.kind === "mcp" && !SERVER_NAME.test(tool.server)))
|
|
66
|
+
return false;
|
|
67
|
+
return (scope?.allow === undefined || scope.allow.some((selector) => matches(selector, tool)))
|
|
68
|
+
&& !scope?.deny?.some((selector) => matches(selector, tool));
|
|
69
|
+
}
|
|
70
|
+
export function allowsTool(scope, tool) {
|
|
71
|
+
const parsed = parseToolScope(scope);
|
|
72
|
+
return parsed.ok && permits(parsed.scope, tool);
|
|
73
|
+
}
|
|
74
|
+
/** Whether an engine needs a native catalog contract, in addition to MCP gates. */
|
|
75
|
+
export function narrowsNativeTools(value) {
|
|
76
|
+
const parsed = parseToolScope(value);
|
|
77
|
+
if (!parsed.ok)
|
|
78
|
+
return true;
|
|
79
|
+
const scope = parsed.scope;
|
|
80
|
+
return scope !== undefined && ((scope.allow !== undefined && !scope.allow.includes("native:*"))
|
|
81
|
+
|| Boolean(scope.deny?.some((selector) => selector.startsWith("native:"))));
|
|
82
|
+
}
|
|
83
|
+
/** Without a catalog, asks whether this server could have a permitted tool. */
|
|
84
|
+
export function canUseMcpServer(scope, server, names) {
|
|
85
|
+
const parsed = parseToolScope(scope);
|
|
86
|
+
if (!parsed.ok || !SERVER_NAME.test(server))
|
|
87
|
+
return false;
|
|
88
|
+
if (names !== undefined) {
|
|
89
|
+
return names.some((name) => permits(parsed.scope, { kind: "mcp", server, name }));
|
|
90
|
+
}
|
|
91
|
+
if (parsed.scope?.deny?.includes(`mcp:${server}:*`))
|
|
92
|
+
return false;
|
|
93
|
+
if (parsed.scope?.allow === undefined)
|
|
94
|
+
return true;
|
|
95
|
+
return parsed.scope.allow.some((selector) => {
|
|
96
|
+
const selected = selectorIdentity(selector);
|
|
97
|
+
return selected?.kind === "mcp" && selected.server === server
|
|
98
|
+
&& (selected.name === "*" || permits(parsed.scope, selected));
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/** Compare the selector partitions, including a tool/server outside all exact names. */
|
|
102
|
+
export function toolScopeWidens(previous, next) {
|
|
103
|
+
const before = parseToolScope(previous);
|
|
104
|
+
const after = parseToolScope(next);
|
|
105
|
+
if (!after.ok)
|
|
106
|
+
return false;
|
|
107
|
+
const namespaces = new Map([["native", new Set()]]);
|
|
108
|
+
const scopes = [before.ok ? before.scope : undefined, after.scope];
|
|
109
|
+
for (const scope of scopes) {
|
|
110
|
+
for (const selector of [...(scope?.allow ?? []), ...(scope?.deny ?? [])]) {
|
|
111
|
+
const tool = selectorIdentity(selector);
|
|
112
|
+
const namespace = tool.kind === "native" ? "native" : `mcp:${tool.server}`;
|
|
113
|
+
if (!namespaces.has(namespace))
|
|
114
|
+
namespaces.set(namespace, new Set());
|
|
115
|
+
if (tool.name !== "*")
|
|
116
|
+
namespaces.get(namespace).add(tool.name);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
let server = "unlisted";
|
|
120
|
+
for (let i = 0; namespaces.has(`mcp:${server}`); i++)
|
|
121
|
+
server = `unlisted${i}`;
|
|
122
|
+
namespaces.set(`mcp:${server}`, new Set());
|
|
123
|
+
for (const [namespace, names] of namespaces) {
|
|
124
|
+
let other = "__unlisted_tool__";
|
|
125
|
+
for (let i = 0; names.has(other); i++)
|
|
126
|
+
other = `__unlisted_tool_${i}__`;
|
|
127
|
+
names.add(other);
|
|
128
|
+
for (const name of names) {
|
|
129
|
+
const tool = namespace === "native" ? { kind: "native", name }
|
|
130
|
+
: { kind: "mcp", server: namespace.slice(4), name };
|
|
131
|
+
if (permits(after.scope, tool) && (!before.ok || !permits(before.scope, tool)))
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
}
|